From 7fd727cf96155f0249a3a7c5360e40cf61f3c9c5 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 23:41:03 -0400 Subject: [PATCH 01/33] Add TotpManager and TOTP utilities --- src/password_manager/totp.py | 61 ++++++++++++++++++++++++++++++++++++ src/requirements.txt | 1 + src/utils/key_derivation.py | 16 ++++++++++ 3 files changed, 78 insertions(+) create mode 100644 src/password_manager/totp.py 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 From 2a724642681b3406d367ecde9e69e595abeb2faf Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 23:50:56 -0400 Subject: [PATCH 02/33] Add BIP85 TOTP derivation --- src/password_manager/password_generation.py | 6 ------ src/tests/test_bip85_vectors.py | 8 ++++---- src/utils/__init__.py | 4 ++++ src/utils/key_derivation.py | 20 ++++++++++++++++++-- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/password_manager/password_generation.py b/src/password_manager/password_generation.py index 670416c..52970ca 100644 --- a/src/password_manager/password_generation.py +++ b/src/password_manager/password_generation.py @@ -335,12 +335,6 @@ class PasswordGenerator: raise -def derive_totp_secret(bip85: BIP85, idx: int) -> str: - """Derive a TOTP secret for the given index using BIP85.""" - entropy = bip85.derive_entropy(index=idx, bytes_len=10, app_no=2) - return base64.b32encode(entropy).decode("utf-8") - - def derive_ssh_key(bip85: BIP85, idx: int) -> bytes: """Derive 32 bytes of entropy suitable for an SSH key.""" return bip85.derive_entropy(index=idx, bytes_len=32, app_no=32) diff --git a/src/tests/test_bip85_vectors.py b/src/tests/test_bip85_vectors.py index 21f872b..8d62aa3 100644 --- a/src/tests/test_bip85_vectors.py +++ b/src/tests/test_bip85_vectors.py @@ -6,10 +6,10 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from local_bip85.bip85 import BIP85, Bip85Error from password_manager.password_generation import ( - derive_totp_secret, derive_ssh_key, derive_seed_phrase, ) +from utils.key_derivation import derive_totp_secret MASTER_XPRV = "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb" @@ -18,7 +18,7 @@ EXPECTED_12 = "girl mad pet galaxy egg matter matrix prison refuse sense ordinar EXPECTED_24 = "puppy ocean match cereal symbol another shed magic wrap hammer bulb intact gadget divorce twin tonight reason outdoor destroy simple truth cigar social volcano" EXPECTED_SYMM_KEY = "7040bb53104f27367f317558e78a994ada7296c6fde36a364e5baf206e502bb1" -EXPECTED_TOTP_SECRET = "OBALWUYQJ4TTM7ZR" +EXPECTED_TOTP_SECRET = "VQYTWDNEWYBY2G3LOGGCEKR4LZ3LNEYY" EXPECTED_SSH_KEY = "52405cd0dd21c5be78314a7c1a3c65ffd8d896536cc7dee3157db5824f0c92e2" @@ -39,8 +39,8 @@ def test_bip85_symmetric_key(bip85): assert bip85.derive_symmetric_key(index=0).hex() == EXPECTED_SYMM_KEY -def test_derive_totp_secret(bip85): - assert derive_totp_secret(bip85, 0) == EXPECTED_TOTP_SECRET +def test_derive_totp_secret(): + assert derive_totp_secret(EXPECTED_24, 0) == EXPECTED_TOTP_SECRET def test_derive_ssh_key(bip85): diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 7ea671d..5a96481 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -11,8 +11,10 @@ try: derive_key_from_password, derive_key_from_parent_seed, derive_index_key, + derive_totp_secret, EncryptionMode, DEFAULT_ENCRYPTION_MODE, + TOTP_PURPOSE, ) from .checksum import ( calculate_checksum, @@ -33,8 +35,10 @@ __all__ = [ "derive_key_from_password", "derive_key_from_parent_seed", "derive_index_key", + "derive_totp_secret", "EncryptionMode", "DEFAULT_ENCRYPTION_MODE", + "TOTP_PURPOSE", "calculate_checksum", "verify_checksum", "json_checksum", diff --git a/src/utils/key_derivation.py b/src/utils/key_derivation.py index 8c164dc..d71b26c 100644 --- a/src/utils/key_derivation.py +++ b/src/utils/key_derivation.py @@ -20,6 +20,7 @@ import base64 import unicodedata import logging import traceback +import hmac from enum import Enum from typing import Optional, Union from bip_utils import Bip39SeedGenerator @@ -40,6 +41,9 @@ class EncryptionMode(Enum): DEFAULT_ENCRYPTION_MODE = EncryptionMode.SEED_ONLY +# Purpose constant for TOTP secret derivation using BIP85 +TOTP_PURPOSE = 39 + def derive_key_from_password(password: str, iterations: int = 100_000) -> bytes: """ @@ -159,10 +163,22 @@ def derive_totp_secret(seed: str, index: int) -> str: try: from local_bip85 import BIP85 + # Initialize BIP85 from the BIP39 seed bytes 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") + + # Build the BIP32 path m/83696968'/39'/TOTP'/{index}' + totp_int = int.from_bytes(b"TOTP", "big") + path = f"m/83696968'/{TOTP_PURPOSE}'/{totp_int}'/{index}'" + + # Derive entropy using the same scheme as BIP85 + child_key = bip85.bip32_ctx.DerivePath(path) + key_bytes = child_key.PrivateKey().Raw().ToBytes() + entropy = hmac.new(b"bip-entropy-from-k", key_bytes, hashlib.sha512).digest() + + # Hash the first 32 bytes of entropy and encode the first 20 bytes + hashed = hashlib.sha256(entropy[:32]).digest() + secret = base64.b32encode(hashed[:20]).decode("utf-8") logger.debug(f"Derived TOTP secret for index {index}: {secret}") return secret except Exception as e: From d1d20759fd519b9b9daee8f875517e7a16a8cd8e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 00:00:43 -0400 Subject: [PATCH 03/33] Implement TOTP entry support --- src/password_manager/entry_management.py | 109 ++++++++++++++++++----- src/tests/test_entry_add.py | 6 ++ src/tests/test_totp_entry.py | 52 +++++++++++ 3 files changed, 143 insertions(+), 24 deletions(-) create mode 100644 src/tests/test_totp_entry.py diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index 9c9fc16..42043b2 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -29,6 +29,7 @@ from pathlib import Path from termcolor import colored from password_manager.migrations import LATEST_VERSION from password_manager.entry_types import EntryType +from password_manager.totp import TotpManager from password_manager.vault import Vault from utils.file_lock import exclusive_lock @@ -59,6 +60,9 @@ class EntryManager: if self.index_file.exists(): try: data = self.vault.load_index() + # Ensure legacy entries without a type are treated as passwords + for entry in data.get("entries", {}).values(): + entry.setdefault("type", EntryType.PASSWORD.value) logger.debug("Index loaded successfully.") return data except Exception as e: @@ -149,16 +153,33 @@ class EntryManager: print(colored(f"Error: Failed to add entry: {e}", "red")) sys.exit(1) - def add_totp(self, notes: str = "") -> int: - """Placeholder for adding a TOTP entry.""" - index = self.get_next_index() + def add_totp( + self, label: str, index: int, period: int = 30, digits: int = 6 + ) -> str: + """Add a new TOTP entry and return the provisioning URI.""" + entry_id = self.get_next_index() data = self.vault.load_index() data.setdefault("entries", {}) - data["entries"][str(index)] = {"type": EntryType.TOTP.value, "notes": notes} + + data["entries"][str(entry_id)] = { + "type": EntryType.TOTP.value, + "label": label, + "index": index, + "period": period, + "digits": digits, + } + self._save_index(data) self.update_checksum() self.backup_index_file() - raise NotImplementedError("TOTP entry support not implemented yet") + + try: + seed = self.vault.encryption_manager.decrypt_parent_seed() + secret = TotpManager.derive_secret(seed, index) + return TotpManager.make_otpauth_uri(label, secret, period, digits) + except Exception as e: + logger.error(f"Failed to generate otpauth URI: {e}") + raise def add_ssh_key(self, notes: str = "") -> int: """Placeholder for adding an SSH key entry.""" @@ -182,6 +203,25 @@ class EntryManager: self.backup_index_file() raise NotImplementedError("Seed entry support not implemented yet") + def get_totp_code(self, index: int, timestamp: int | None = None) -> str: + """Return the current TOTP code for the specified entry.""" + entry = self.retrieve_entry(index) + if not entry or entry.get("type") != EntryType.TOTP.value: + raise ValueError("Entry is not a TOTP entry") + + seed = self.vault.encryption_manager.decrypt_parent_seed() + totp_index = int(entry.get("index", 0)) + return TotpManager.current_code(seed, totp_index, timestamp) + + def get_totp_time_remaining(self, index: int) -> int: + """Return seconds remaining in the TOTP period for the given entry.""" + entry = self.retrieve_entry(index) + if not entry or entry.get("type") != EntryType.TOTP.value: + raise ValueError("Entry is not a TOTP entry") + + period = int(entry.get("period", 30)) + return TotpManager.time_remaining(period) + def get_encrypted_index(self) -> Optional[bytes]: """ Retrieves the encrypted password index file's contents. @@ -295,11 +335,7 @@ class EntryManager: ) def list_entries(self) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]: - """ - Lists all entries in the index. - - :return: A list of tuples containing entry details: (index, website, username, url, blacklisted) - """ + """List all entries in the index.""" try: data = self.vault.load_index() entries_data = data.get("entries", {}) @@ -311,23 +347,48 @@ class EntryManager: entries = [] for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])): - entries.append( - ( - int(idx), - entry.get("website", ""), - entry.get("username", ""), - entry.get("url", ""), - entry.get("blacklisted", False), + etype = entry.get("type", EntryType.PASSWORD.value) + if etype == EntryType.TOTP.value: + entries.append( + (int(idx), entry.get("label", ""), None, None, False) + ) + else: + entries.append( + ( + int(idx), + entry.get("website", ""), + entry.get("username", ""), + entry.get("url", ""), + entry.get("blacklisted", False), + ) ) - ) logger.debug(f"Total entries found: {len(entries)}") - for entry in entries: - print(colored(f"Index: {entry[0]}", "cyan")) - print(colored(f" Website: {entry[1]}", "cyan")) - print(colored(f" Username: {entry[2] or 'N/A'}", "cyan")) - print(colored(f" URL: {entry[3] or 'N/A'}", "cyan")) - print(colored(f" Blacklisted: {'Yes' if entry[4] else 'No'}", "cyan")) + for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])): + etype = entry.get("type", EntryType.PASSWORD.value) + print(colored(f"Index: {idx}", "cyan")) + if etype == EntryType.TOTP.value: + print(colored(" Type: TOTP", "cyan")) + print(colored(f" Label: {entry.get('label', '')}", "cyan")) + print(colored(f" Derivation Index: {entry.get('index')}", "cyan")) + print( + colored( + f" Period: {entry.get('period', 30)}s Digits: {entry.get('digits', 6)}", + "cyan", + ) + ) + else: + print(colored(f" Website: {entry.get('website', '')}", "cyan")) + print( + colored(f" Username: {entry.get('username') or 'N/A'}", "cyan") + ) + print(colored(f" URL: {entry.get('url') or 'N/A'}", "cyan")) + print( + colored( + f" Blacklisted: {'Yes' if entry.get('blacklisted', False) else 'No'}", + "cyan", + ) + ) print("-" * 40) return entries diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py index fce73f4..d96f1c3 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -1,6 +1,8 @@ import sys from pathlib import Path from tempfile import TemporaryDirectory +from unittest.mock import patch + import pytest from helpers import create_vault, TEST_SEED, TEST_PASSWORD @@ -49,6 +51,10 @@ def test_round_trip_entry_types(method, expected_type): if method == "add_entry": index = entry_mgr.add_entry("example.com", 8) + elif method == "add_totp": + with patch.object(enc_mgr, "decrypt_parent_seed", return_value=TEST_SEED): + entry_mgr.add_totp("example", 0) + index = 0 else: with pytest.raises(NotImplementedError): getattr(entry_mgr, method)() diff --git a/src/tests/test_totp_entry.py b/src/tests/test_totp_entry.py new file mode 100644 index 0000000..371ceaa --- /dev/null +++ b/src/tests/test_totp_entry.py @@ -0,0 +1,52 @@ +import sys +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +import pytest + +from helpers import create_vault, TEST_SEED, TEST_PASSWORD + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.entry_management import EntryManager +from password_manager.vault import Vault +from password_manager.totp import TotpManager + + +def test_add_totp_and_get_code(): + with TemporaryDirectory() as tmpdir: + vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) + entry_mgr = EntryManager(vault, Path(tmpdir)) + + with patch.object(enc_mgr, "decrypt_parent_seed", return_value=TEST_SEED): + uri = entry_mgr.add_totp("Example", 0) + assert uri.startswith("otpauth://totp/") + + entry = entry_mgr.retrieve_entry(0) + assert entry == { + "type": "totp", + "label": "Example", + "index": 0, + "period": 30, + "digits": 6, + } + + with patch.object(enc_mgr, "decrypt_parent_seed", return_value=TEST_SEED): + code = entry_mgr.get_totp_code(0, timestamp=0) + + expected = TotpManager.current_code(TEST_SEED, 0, timestamp=0) + assert code == expected + + +def test_totp_time_remaining(monkeypatch): + with TemporaryDirectory() as tmpdir: + vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) + entry_mgr = EntryManager(vault, Path(tmpdir)) + + with patch.object(enc_mgr, "decrypt_parent_seed", return_value=TEST_SEED): + entry_mgr.add_totp("Example", 0) + + monkeypatch.setattr(TotpManager, "time_remaining", lambda period: 7) + remaining = entry_mgr.get_totp_time_remaining(0) + assert remaining == 7 From a8cfa6f4d324f57a1fabb91b742448b6c43b6205 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 00:08:00 -0400 Subject: [PATCH 04/33] Add TOTP tests and legacy entry type check --- src/requirements.txt | 2 ++ src/tests/test_entry_add.py | 15 +++++++++++++++ src/tests/test_totp.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 src/tests/test_totp.py diff --git a/src/requirements.txt b/src/requirements.txt index d7b2add..29ad75f 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -19,3 +19,5 @@ tomli hypothesis mutmut==2.4.4 pyotp>=2.8.0 + +freezegun diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py index d96f1c3..a64b068 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -64,3 +64,18 @@ def test_round_trip_entry_types(method, expected_type): assert entry["type"] == expected_type data = enc_mgr.load_json_data(entry_mgr.index_file) assert data["entries"][str(index)]["type"] == expected_type + + +def test_legacy_entry_defaults_to_password(): + with TemporaryDirectory() as tmpdir: + vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) + entry_mgr = EntryManager(vault, Path(tmpdir)) + + index = entry_mgr.add_entry("example.com", 8) + + data = enc_mgr.load_json_data(entry_mgr.index_file) + data["entries"][str(index)].pop("type", None) + enc_mgr.save_json_data(data, entry_mgr.index_file) + + loaded = entry_mgr._load_index() + assert loaded["entries"][str(index)]["type"] == "password" diff --git a/src/tests/test_totp.py b/src/tests/test_totp.py new file mode 100644 index 0000000..ddaacd9 --- /dev/null +++ b/src/tests/test_totp.py @@ -0,0 +1,30 @@ +import sys +from pathlib import Path + +import pyotp +from freezegun import freeze_time + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from helpers import TEST_SEED +from password_manager.totp import TotpManager + + +@freeze_time("1970-01-01 00:16:40") +def test_current_code_matches_pyotp(): + secret = TotpManager.derive_secret(TEST_SEED, 0) + expected = pyotp.TOTP(secret).now() + assert TotpManager.current_code(TEST_SEED, 0) == expected + + +@freeze_time("1970-01-01 00:00:15") +def test_time_remaining(): + assert TotpManager.time_remaining(period=30) == 15 + + +def test_print_progress_bar_terminates(monkeypatch): + monkeypatch.setattr(TotpManager, "time_remaining", lambda period: 0) + calls = [] + monkeypatch.setattr("password_manager.totp.time.sleep", lambda s: calls.append(s)) + TotpManager.print_progress_bar(period=30) + assert calls == [] From 8249b0cf3268e50f898ca02cc722cafc53b15dae Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 00:16:55 -0400 Subject: [PATCH 05/33] Add get-code example and 2FA note --- README.md | 1 + docs/README.md | 15 +++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 docs/README.md diff --git a/README.md b/README.md index 97456e7..c301bc5 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - **Checksum Verification:** Ensure the integrity of the script with checksum verification. - **Multiple Seed Profiles:** Manage separate seed profiles and switch between them seamlessly. - **Interactive TUI:** Navigate through menus to add, retrieve, and modify entries as well as configure Nostr settings. +- **SeedPass 2FA:** Generate TOTP codes with a real-time countdown progress bar. ## Prerequisites diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..42dad0d --- /dev/null +++ b/docs/README.md @@ -0,0 +1,15 @@ +# SeedPass Documentation + +This directory contains supplementary guides for using SeedPass. + +## Quick Example: Get a TOTP Code + +Run `seedpass get-code` to retrieve a time-based one-time password (TOTP). A progress bar shows the remaining seconds in the current period. + +```bash +$ seedpass get-code --index 0 +[##########----------] 15s +Code: 123456 +``` + +See [advanced_cli.md](advanced_cli.md) for a full command reference. From 5afe8f8f645b7f84ed3c3229782d59acf54e4f63 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 00:19:09 -0400 Subject: [PATCH 06/33] Move dark mode toggle to bottom left --- landing/style.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/landing/style.css b/landing/style.css index bbc7410..44434a9 100644 --- a/landing/style.css +++ b/landing/style.css @@ -90,8 +90,8 @@ body.dark-mode { /* Dark Mode Toggle */ .dark-mode-toggle { position: fixed; - top: 12px; - right: 20px; + bottom: 12px; + left: 20px; z-index: 1000; } @@ -856,8 +856,8 @@ footer .social-media a:focus { } .dark-mode-toggle { - top: 10px; - right: 10px; + bottom: 10px; + left: 10px; } /* Adjust disclaimer container padding on smaller screens */ From de7beeaefce9f6378565a66eeb99c13092a71ff0 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 00:27:18 -0400 Subject: [PATCH 07/33] Add manual 2FA entry option --- README.md | 11 ++++++ src/main.py | 6 +++- src/password_manager/manager.py | 56 +++++++++++++++++++++++++++++ src/tests/test_cli_invalid_input.py | 3 +- 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c301bc5..6256f76 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,17 @@ python src/main.py Enter your choice (1-5): ``` + When choosing **Add Entry**, you can now select **Password** or **2FA (TOTP)**. + +### Adding a 2FA Entry + +1. From the main menu choose **Add Entry** and select **2FA (TOTP)**. +2. Provide a label for the account (for example, `GitHub`). +3. Enter the derivation index you wish to use for this 2FA code. +4. Optionally specify the TOTP period and digit count. +5. SeedPass will display an `otpauth://` URI and secret that you can manually + enter into your authenticator app. + ### Managing Multiple Seeds diff --git a/src/main.py b/src/main.py index 01eeb95..5838e6d 100644 --- a/src/main.py +++ b/src/main.py @@ -591,13 +591,17 @@ def display_menu( while True: print("\nAdd Entry:") print("1. Password") - print("2. Back") + print("2. 2FA (TOTP)") + print("3. Back") sub_choice = input("Select entry type: ").strip() password_manager.update_activity() if sub_choice == "1": password_manager.handle_add_password() break elif sub_choice == "2": + password_manager.handle_add_totp() + break + elif sub_choice == "3": break else: print(colored("Invalid choice.", "red")) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 444ffaf..154602c 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -862,6 +862,62 @@ class PasswordManager: logging.error(f"Error during password generation: {e}", exc_info=True) print(colored(f"Error: Failed to generate password: {e}", "red")) + def handle_add_totp(self) -> None: + """Prompt for details and add a new TOTP entry.""" + try: + label = input("Enter the account label: ").strip() + if not label: + print(colored("Error: Label cannot be empty.", "red")) + return + + index_input = input("Enter derivation index (number): ").strip() + if not index_input.isdigit(): + print(colored("Error: Index must be a number.", "red")) + return + totp_index = int(index_input) + + period_input = input("TOTP period in seconds (default 30): ").strip() + period = 30 + if period_input: + if not period_input.isdigit(): + print(colored("Error: Period must be a number.", "red")) + return + period = int(period_input) + + digits_input = input("Number of digits (default 6): ").strip() + digits = 6 + if digits_input: + if not digits_input.isdigit(): + print(colored("Error: Digits must be a number.", "red")) + return + digits = int(digits_input) + + entry_id = self.entry_manager.get_next_index() + uri = self.entry_manager.add_totp(label, totp_index, period, digits) + + self.is_dirty = True + self.last_update = time.time() + + secret = TotpManager.derive_secret(self.parent_seed, totp_index) + + print(colored(f"\n[+] TOTP entry added with ID {entry_id}.\n", "green")) + print(colored("Add this URI to your authenticator app:", "cyan")) + print(colored(uri, "yellow")) + print(colored(f"Secret: {secret}\n", "cyan")) + + try: + self.sync_vault() + logging.info("Encrypted index posted to Nostr after TOTP add.") + except Exception as nostr_error: + logging.error( + f"Failed to post updated index to Nostr: {nostr_error}", + exc_info=True, + ) + + except Exception as e: + logging.error(f"Error during TOTP setup: {e}", exc_info=True) + print(colored(f"Error: Failed to add TOTP: {e}", "red")) + def handle_retrieve_entry(self) -> None: """ Handles retrieving a password from the index by prompting the user for the index number diff --git a/src/tests/test_cli_invalid_input.py b/src/tests/test_cli_invalid_input.py index cb65f0d..5e29ba4 100644 --- a/src/tests/test_cli_invalid_input.py +++ b/src/tests/test_cli_invalid_input.py @@ -39,6 +39,7 @@ def _make_pm(called, locked=None): last_activity=time.time(), nostr_client=SimpleNamespace(close_client_pool=lambda: None), handle_add_password=add, + handle_add_totp=lambda: None, handle_retrieve_entry=retrieve, handle_modify_entry=modify, update_activity=update, @@ -76,7 +77,7 @@ def test_out_of_range_menu(monkeypatch, capsys): def test_invalid_add_entry_submenu(monkeypatch, capsys): called = {"add": False, "retrieve": False, "modify": False} pm, _ = _make_pm(called) - inputs = iter(["1", "3", "2", "5"]) + inputs = iter(["1", "4", "3", "5"]) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) with pytest.raises(SystemExit): From 82306a3a4bedaa6e49fd51a75967646e995f6a21 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 07:39:13 -0400 Subject: [PATCH 08/33] Fix TOTP seed retrieval and auto index --- README.md | 2 +- src/password_manager/entry_management.py | 30 +++++++++++++++++++----- src/password_manager/manager.py | 14 ++++++----- src/tests/test_entry_add.py | 3 +-- src/tests/test_totp_entry.py | 11 ++++----- 5 files changed, 38 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 6256f76..bc5a3d8 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ python src/main.py 1. From the main menu choose **Add Entry** and select **2FA (TOTP)**. 2. Provide a label for the account (for example, `GitHub`). -3. Enter the derivation index you wish to use for this 2FA code. +3. SeedPass automatically chooses the next available derivation index. 4. Optionally specify the TOTP period and digit count. 5. SeedPass will display an `otpauth://` URI and secret that you can manually enter into your authenticator app. diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index 42043b2..70f1832 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -153,11 +153,29 @@ class EntryManager: print(colored(f"Error: Failed to add entry: {e}", "red")) sys.exit(1) + def get_next_totp_index(self) -> int: + """Return the next available derivation index for TOTP secrets.""" + data = self.vault.load_index() + entries = data.get("entries", {}) + indices = [ + int(v.get("index", 0)) + for v in entries.values() + if v.get("type") == EntryType.TOTP.value + ] + return (max(indices) + 1) if indices else 0 + def add_totp( - self, label: str, index: int, period: int = 30, digits: int = 6 + self, + label: str, + parent_seed: str, + index: int | None = None, + period: int = 30, + digits: int = 6, ) -> str: """Add a new TOTP entry and return the provisioning URI.""" entry_id = self.get_next_index() + if index is None: + index = self.get_next_totp_index() data = self.vault.load_index() data.setdefault("entries", {}) @@ -174,8 +192,7 @@ class EntryManager: self.backup_index_file() try: - seed = self.vault.encryption_manager.decrypt_parent_seed() - secret = TotpManager.derive_secret(seed, index) + secret = TotpManager.derive_secret(parent_seed, index) return TotpManager.make_otpauth_uri(label, secret, period, digits) except Exception as e: logger.error(f"Failed to generate otpauth URI: {e}") @@ -203,15 +220,16 @@ class EntryManager: self.backup_index_file() raise NotImplementedError("Seed entry support not implemented yet") - def get_totp_code(self, index: int, timestamp: int | None = None) -> str: + def get_totp_code( + self, index: int, parent_seed: str, timestamp: int | None = None + ) -> str: """Return the current TOTP code for the specified entry.""" entry = self.retrieve_entry(index) if not entry or entry.get("type") != EntryType.TOTP.value: raise ValueError("Entry is not a TOTP entry") - seed = self.vault.encryption_manager.decrypt_parent_seed() totp_index = int(entry.get("index", 0)) - return TotpManager.current_code(seed, totp_index, timestamp) + return TotpManager.current_code(parent_seed, totp_index, timestamp) def get_totp_time_remaining(self, index: int) -> int: """Return seconds remaining in the TOTP period for the given entry.""" diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 154602c..f324547 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -870,11 +870,7 @@ class PasswordManager: print(colored("Error: Label cannot be empty.", "red")) return - index_input = input("Enter derivation index (number): ").strip() - if not index_input.isdigit(): - print(colored("Error: Index must be a number.", "red")) - return - totp_index = int(index_input) + totp_index = self.entry_manager.get_next_totp_index() period_input = input("TOTP period in seconds (default 30): ").strip() period = 30 @@ -893,7 +889,13 @@ class PasswordManager: digits = int(digits_input) entry_id = self.entry_manager.get_next_index() - uri = self.entry_manager.add_totp(label, totp_index, period, digits) + uri = self.entry_manager.add_totp( + label, + self.parent_seed, + index=totp_index, + period=period, + digits=digits, + ) self.is_dirty = True self.last_update = time.time() diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py index a64b068..71731b8 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -52,8 +52,7 @@ def test_round_trip_entry_types(method, expected_type): if method == "add_entry": index = entry_mgr.add_entry("example.com", 8) elif method == "add_totp": - with patch.object(enc_mgr, "decrypt_parent_seed", return_value=TEST_SEED): - entry_mgr.add_totp("example", 0) + entry_mgr.add_totp("example", TEST_SEED) index = 0 else: with pytest.raises(NotImplementedError): diff --git a/src/tests/test_totp_entry.py b/src/tests/test_totp_entry.py index 371ceaa..a6d946a 100644 --- a/src/tests/test_totp_entry.py +++ b/src/tests/test_totp_entry.py @@ -19,9 +19,8 @@ def test_add_totp_and_get_code(): vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) entry_mgr = EntryManager(vault, Path(tmpdir)) - with patch.object(enc_mgr, "decrypt_parent_seed", return_value=TEST_SEED): - uri = entry_mgr.add_totp("Example", 0) - assert uri.startswith("otpauth://totp/") + uri = entry_mgr.add_totp("Example", TEST_SEED) + assert uri.startswith("otpauth://totp/") entry = entry_mgr.retrieve_entry(0) assert entry == { @@ -32,8 +31,7 @@ def test_add_totp_and_get_code(): "digits": 6, } - with patch.object(enc_mgr, "decrypt_parent_seed", return_value=TEST_SEED): - code = entry_mgr.get_totp_code(0, timestamp=0) + code = entry_mgr.get_totp_code(0, TEST_SEED, timestamp=0) expected = TotpManager.current_code(TEST_SEED, 0, timestamp=0) assert code == expected @@ -44,8 +42,7 @@ def test_totp_time_remaining(monkeypatch): vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) entry_mgr = EntryManager(vault, Path(tmpdir)) - with patch.object(enc_mgr, "decrypt_parent_seed", return_value=TEST_SEED): - entry_mgr.add_totp("Example", 0) + entry_mgr.add_totp("Example", TEST_SEED) monkeypatch.setattr(TotpManager, "time_remaining", lambda period: 7) remaining = entry_mgr.get_totp_time_remaining(0) From c327d640447a14e0034110eb1a651f1e9e57413d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 07:49:14 -0400 Subject: [PATCH 09/33] Fix TOTP manager import and add test --- src/password_manager/manager.py | 1 + src/tests/test_manager_add_totp.py | 61 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 src/tests/test_manager_add_totp.py diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index f324547..009efd9 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -25,6 +25,7 @@ from password_manager.password_generation import PasswordGenerator from password_manager.backup import BackupManager from password_manager.vault import Vault from password_manager.portable_backup import export_backup, import_backup +from password_manager.totp import TotpManager from utils.key_derivation import ( derive_key_from_parent_seed, derive_key_from_password, diff --git a/src/tests/test_manager_add_totp.py b/src/tests/test_manager_add_totp.py new file mode 100644 index 0000000..0540927 --- /dev/null +++ b/src/tests/test_manager_add_totp.py @@ -0,0 +1,61 @@ +import sys +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace + +from helpers import create_vault, TEST_SEED, TEST_PASSWORD + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager +from password_manager.manager import PasswordManager, EncryptionMode + + +class FakeNostrClient: + def __init__(self, *args, **kwargs): + self.published = [] + + def publish_snapshot(self, data: bytes): + self.published.append(data) + return None, "abcd" + + +def test_handle_add_totp(monkeypatch): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + entry_mgr = EntryManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.parent_seed = TEST_SEED + pm.nostr_client = FakeNostrClient() + pm.fingerprint_dir = tmp_path + pm.is_dirty = False + + inputs = iter( + [ + "Example", # label + "", # period + "", # digits + ] + ) + monkeypatch.setattr("builtins.input", lambda *args, **kwargs: next(inputs)) + monkeypatch.setattr(pm, "sync_vault", lambda: None) + + pm.handle_add_totp() + + entry = entry_mgr.retrieve_entry(0) + assert entry == { + "type": "totp", + "label": "Example", + "index": 0, + "period": 30, + "digits": 6, + } From 7308fe7ea64c04db431e77d813e38cf3fc808f08 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 08:14:37 -0400 Subject: [PATCH 10/33] Add support for imported TOTP secrets --- src/password_manager/entry_management.py | 40 +++++-- src/password_manager/manager.py | 139 ++++++++++++++--------- src/password_manager/totp.py | 22 ++++ src/tests/test_manager_add_totp.py | 1 + src/tests/test_totp_entry.py | 18 +++ 5 files changed, 157 insertions(+), 63 deletions(-) diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index 70f1832..87285ca 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -168,31 +168,44 @@ class EntryManager: self, label: str, parent_seed: str, + *, + secret: str | None = None, index: int | None = None, period: int = 30, digits: int = 6, ) -> str: """Add a new TOTP entry and return the provisioning URI.""" entry_id = self.get_next_index() - if index is None: - index = self.get_next_totp_index() data = self.vault.load_index() data.setdefault("entries", {}) - data["entries"][str(entry_id)] = { - "type": EntryType.TOTP.value, - "label": label, - "index": index, - "period": period, - "digits": digits, - } + if secret is None: + if index is None: + index = self.get_next_totp_index() + secret = TotpManager.derive_secret(parent_seed, index) + entry = { + "type": EntryType.TOTP.value, + "label": label, + "index": index, + "period": period, + "digits": digits, + } + else: + entry = { + "type": EntryType.TOTP.value, + "label": label, + "secret": secret, + "period": period, + "digits": digits, + } + + data["entries"][str(entry_id)] = entry self._save_index(data) self.update_checksum() self.backup_index_file() try: - secret = TotpManager.derive_secret(parent_seed, index) return TotpManager.make_otpauth_uri(label, secret, period, digits) except Exception as e: logger.error(f"Failed to generate otpauth URI: {e}") @@ -221,13 +234,16 @@ class EntryManager: raise NotImplementedError("Seed entry support not implemented yet") def get_totp_code( - self, index: int, parent_seed: str, timestamp: int | None = None + self, index: int, parent_seed: str | None = None, timestamp: int | None = None ) -> str: """Return the current TOTP code for the specified entry.""" entry = self.retrieve_entry(index) if not entry or entry.get("type") != EntryType.TOTP.value: raise ValueError("Entry is not a TOTP entry") - + if "secret" in entry: + return TotpManager.current_code_from_secret(entry["secret"], timestamp) + if parent_seed is None: + raise ValueError("Seed required for derived TOTP") totp_index = int(entry.get("index", 0)) return TotpManager.current_code(parent_seed, totp_index, timestamp) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 009efd9..9eec67f 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -864,59 +864,96 @@ class PasswordManager: print(colored(f"Error: Failed to generate password: {e}", "red")) def handle_add_totp(self) -> None: - """Prompt for details and add a new TOTP entry.""" + """Add a TOTP entry either derived from the seed or imported.""" try: - label = input("Enter the account label: ").strip() - if not label: - print(colored("Error: Label cannot be empty.", "red")) - return - - totp_index = self.entry_manager.get_next_totp_index() - - period_input = input("TOTP period in seconds (default 30): ").strip() - period = 30 - if period_input: - if not period_input.isdigit(): - print(colored("Error: Period must be a number.", "red")) + while True: + print("\nAdd TOTP:") + print("1. Make 2FA (derive from seed)") + print("2. Import 2FA (paste otpauth URI or secret)") + print("3. Back") + choice = input("Select option: ").strip() + if choice == "1": + label = input("Label: ").strip() + if not label: + print(colored("Error: Label cannot be empty.", "red")) + continue + period = input("Period (default 30): ").strip() or "30" + digits = input("Digits (default 6): ").strip() or "6" + if not period.isdigit() or not digits.isdigit(): + print( + colored("Error: Period and digits must be numbers.", "red") + ) + continue + totp_index = self.entry_manager.get_next_totp_index() + entry_id = self.entry_manager.get_next_index() + uri = self.entry_manager.add_totp( + label, + self.parent_seed, + index=totp_index, + period=int(period), + digits=int(digits), + ) + secret = TotpManager.derive_secret(self.parent_seed, totp_index) + self.is_dirty = True + self.last_update = time.time() + print( + colored( + f"\n[+] TOTP entry added with ID {entry_id}.\n", "green" + ) + ) + print(colored("Add this URI to your authenticator app:", "cyan")) + print(colored(uri, "yellow")) + print(colored(f"Secret: {secret}\n", "cyan")) + try: + self.sync_vault() + except Exception as nostr_error: + logging.error( + f"Failed to post updated index to Nostr: {nostr_error}", + exc_info=True, + ) + break + elif choice == "2": + raw = input("Paste otpauth URI or secret: ").strip() + try: + if raw.lower().startswith("otpauth://"): + label, secret, period, digits = TotpManager.parse_otpauth( + raw + ) + else: + label = input("Label: ").strip() + secret = raw.upper() + period = int(input("Period (default 30): ").strip() or 30) + digits = int(input("Digits (default 6): ").strip() or 6) + entry_id = self.entry_manager.get_next_index() + uri = self.entry_manager.add_totp( + label, + self.parent_seed, + secret=secret, + period=period, + digits=digits, + ) + self.is_dirty = True + self.last_update = time.time() + print( + colored( + f"\nImported \u2714 Codes for {label} are now stored in SeedPass.", + "green", + ) + ) + try: + self.sync_vault() + except Exception as nostr_error: + logging.error( + f"Failed to post updated index to Nostr: {nostr_error}", + exc_info=True, + ) + break + except ValueError as err: + print(colored(f"Error: {err}", "red")) + elif choice == "3": return - period = int(period_input) - - digits_input = input("Number of digits (default 6): ").strip() - digits = 6 - if digits_input: - if not digits_input.isdigit(): - print(colored("Error: Digits must be a number.", "red")) - return - digits = int(digits_input) - - entry_id = self.entry_manager.get_next_index() - uri = self.entry_manager.add_totp( - label, - self.parent_seed, - index=totp_index, - period=period, - digits=digits, - ) - - self.is_dirty = True - self.last_update = time.time() - - secret = TotpManager.derive_secret(self.parent_seed, totp_index) - - print(colored(f"\n[+] TOTP entry added with ID {entry_id}.\n", "green")) - print(colored("Add this URI to your authenticator app:", "cyan")) - print(colored(uri, "yellow")) - print(colored(f"Secret: {secret}\n", "cyan")) - - try: - self.sync_vault() - logging.info("Encrypted index posted to Nostr after TOTP add.") - except Exception as nostr_error: - logging.error( - f"Failed to post updated index to Nostr: {nostr_error}", - exc_info=True, - ) - + else: + print(colored("Invalid choice.", "red")) except Exception as e: logging.error(f"Error during TOTP setup: {e}", exc_info=True) print(colored(f"Error: Failed to add TOTP: {e}", "red")) diff --git a/src/password_manager/totp.py b/src/password_manager/totp.py index 4e50e22..a6a88b6 100644 --- a/src/password_manager/totp.py +++ b/src/password_manager/totp.py @@ -5,6 +5,7 @@ from __future__ import annotations import sys import time from urllib.parse import quote +from urllib.parse import urlparse, parse_qs, unquote import pyotp @@ -28,6 +29,27 @@ class TotpManager: return totp.now() return totp.at(timestamp) + @staticmethod + def current_code_from_secret(secret: str, timestamp: int | None = None) -> str: + """Return the TOTP code for a raw secret.""" + totp = pyotp.TOTP(secret) + return totp.now() if timestamp is None else totp.at(timestamp) + + @staticmethod + def parse_otpauth(uri: str) -> tuple[str, str, int, int]: + """Parse an otpauth URI and return (label, secret, period, digits).""" + if not uri.startswith("otpauth://"): + raise ValueError("Not an otpauth URI") + parsed = urlparse(uri) + label = unquote(parsed.path.lstrip("/")) + qs = parse_qs(parsed.query) + secret = qs.get("secret", [""])[0].upper() + period = int(qs.get("period", ["30"])[0]) + digits = int(qs.get("digits", ["6"])[0]) + if not secret: + raise ValueError("Missing secret in URI") + return label, secret, period, digits + @staticmethod def make_otpauth_uri( label: str, secret: str, period: int = 30, digits: int = 6 diff --git a/src/tests/test_manager_add_totp.py b/src/tests/test_manager_add_totp.py index 0540927..6ee18b9 100644 --- a/src/tests/test_manager_add_totp.py +++ b/src/tests/test_manager_add_totp.py @@ -41,6 +41,7 @@ def test_handle_add_totp(monkeypatch): inputs = iter( [ + "1", # choose derive "Example", # label "", # period "", # digits diff --git a/src/tests/test_totp_entry.py b/src/tests/test_totp_entry.py index a6d946a..d79aa41 100644 --- a/src/tests/test_totp_entry.py +++ b/src/tests/test_totp_entry.py @@ -12,6 +12,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.entry_management import EntryManager from password_manager.vault import Vault from password_manager.totp import TotpManager +import pyotp def test_add_totp_and_get_code(): @@ -47,3 +48,20 @@ def test_totp_time_remaining(monkeypatch): monkeypatch.setattr(TotpManager, "time_remaining", lambda period: 7) remaining = entry_mgr.get_totp_time_remaining(0) assert remaining == 7 + + +def test_add_totp_imported(tmp_path): + vault, enc = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + em = EntryManager(vault, tmp_path) + secret = "JBSWY3DPEHPK3PXP" + em.add_totp("Imported", TEST_SEED, secret=secret) + entry = em.retrieve_entry(0) + assert entry == { + "type": "totp", + "label": "Imported", + "secret": secret, + "period": 30, + "digits": 6, + } + code = em.get_totp_code(0, timestamp=0) + assert code == pyotp.TOTP(secret).at(0) From c7ffdd6991f8a00c40b7a1699b5deff159a25738 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 08:30:43 -0400 Subject: [PATCH 11/33] Add 2FA codes menu --- README.md | 7 +++--- landing/index.html | 7 +++--- src/main.py | 14 +++++++----- src/password_manager/manager.py | 35 +++++++++++++++++++++++++++++ src/tests/test_auto_sync.py | 2 +- src/tests/test_cli_invalid_input.py | 8 +++---- src/tests/test_inactivity_lock.py | 4 ++-- 7 files changed, 59 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index bc5a3d8..ac46ab2 100644 --- a/README.md +++ b/README.md @@ -167,10 +167,11 @@ python src/main.py 1. Add Entry 2. Retrieve Entry 3. Modify an Existing Entry - 4. Settings - 5. Exit + 4. 2FA Codes + 5. Settings + 6. Exit - Enter your choice (1-5): + Enter your choice (1-6): ``` When choosing **Add Entry**, you can now select **Password** or **2FA (TOTP)**. diff --git a/landing/index.html b/landing/index.html index 16e87b3..cce4cf0 100644 --- a/landing/index.html +++ b/landing/index.html @@ -101,10 +101,11 @@ Select an option: 1. Add Entry 2. Retrieve Entry 3. Modify an Existing Entry -4. Settings -5. Exit +4. 2FA Codes +5. Settings +6. Exit -Enter your choice (1-5): +Enter your choice (1-6): diff --git a/src/main.py b/src/main.py index 5838e6d..cecfd63 100644 --- a/src/main.py +++ b/src/main.py @@ -548,8 +548,9 @@ def display_menu( 1. Add Entry 2. Retrieve Entry 3. Modify an Existing Entry - 4. Settings - 5. Exit + 4. 2FA Codes + 5. Settings + 6. Exit """ while True: if time.time() - password_manager.last_activity > inactivity_timeout: @@ -571,7 +572,7 @@ def display_menu( print(colored(menu, "cyan")) try: choice = timed_input( - "Enter your choice (1-5): ", inactivity_timeout + "Enter your choice (1-6): ", inactivity_timeout ).strip() except TimeoutError: print(colored("Session timed out. Vault locked.", "yellow")) @@ -582,7 +583,7 @@ def display_menu( if not choice: print( colored( - "No input detected. Please enter a number between 1 and 5.", + "No input detected. Please enter a number between 1 and 6.", "yellow", ) ) @@ -613,8 +614,11 @@ def display_menu( password_manager.handle_modify_entry() elif choice == "4": password_manager.update_activity() - handle_settings(password_manager) + password_manager.handle_display_totp_codes() elif choice == "5": + password_manager.update_activity() + handle_settings(password_manager) + elif choice == "6": logging.info("Exiting the program.") print(colored("Exiting the program.", "green")) password_manager.nostr_client.close_client_pool() diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 9eec67f..1f0fe2d 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1178,6 +1178,41 @@ class PasswordManager: logging.error(f"Error during entry deletion: {e}", exc_info=True) print(colored(f"Error: Failed to delete entry: {e}", "red")) + def handle_display_totp_codes(self) -> None: + """Display all stored TOTP codes with a countdown progress bar.""" + try: + data = self.entry_manager.vault.load_index() + entries = data.get("entries", {}) + totp_list: list[tuple[str, int, int]] = [] + for idx_str, entry in entries.items(): + if entry.get("type") == EntryType.TOTP.value: + label = entry.get("label", "") + period = int(entry.get("period", 30)) + totp_list.append((label, int(idx_str), period)) + + if not totp_list: + print(colored("No 2FA entries found.", "yellow")) + return + + totp_list.sort(key=lambda t: t[0].lower()) + + print(colored("Press Ctrl+C to return to the menu.", "cyan")) + while True: + print("\033c", end="") + for label, idx, period in totp_list: + code = self.entry_manager.get_totp_code(idx, self.parent_seed) + remaining = self.entry_manager.get_totp_time_remaining(idx) + filled = int(20 * (period - remaining) / period) + bar = "[" + "#" * filled + "-" * (20 - filled) + "]" + print(f"{label}: {code} {bar} {remaining:2d}s") + sys.stdout.flush() + time.sleep(1) + except KeyboardInterrupt: + print() + except Exception as e: + logging.error(f"Error displaying TOTP codes: {e}", exc_info=True) + print(colored(f"Error: Failed to display TOTP codes: {e}", "red")) + def handle_verify_checksum(self) -> None: """ Handles verifying the script's checksum against the stored checksum to ensure integrity. diff --git a/src/tests/test_auto_sync.py b/src/tests/test_auto_sync.py index 1207e10..e968c8d 100644 --- a/src/tests/test_auto_sync.py +++ b/src/tests/test_auto_sync.py @@ -31,7 +31,7 @@ def test_auto_sync_triggers_post(monkeypatch): called = True monkeypatch.setattr(main, "handle_post_to_nostr", fake_post) - monkeypatch.setattr(main, "timed_input", lambda *_: "5") + monkeypatch.setattr(main, "timed_input", lambda *_: "6") with pytest.raises(SystemExit): main.display_menu(pm, sync_interval=0.1) diff --git a/src/tests/test_cli_invalid_input.py b/src/tests/test_cli_invalid_input.py index 5e29ba4..50760a2 100644 --- a/src/tests/test_cli_invalid_input.py +++ b/src/tests/test_cli_invalid_input.py @@ -52,7 +52,7 @@ def _make_pm(called, locked=None): def test_empty_and_non_numeric_choice(monkeypatch, capsys): called = {"add": False, "retrieve": False, "modify": False} pm, _ = _make_pm(called) - inputs = iter(["", "abc", "5"]) + inputs = iter(["", "abc", "6"]) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) with pytest.raises(SystemExit): main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) @@ -65,7 +65,7 @@ def test_empty_and_non_numeric_choice(monkeypatch, capsys): def test_out_of_range_menu(monkeypatch, capsys): called = {"add": False, "retrieve": False, "modify": False} pm, _ = _make_pm(called) - inputs = iter(["9", "5"]) + inputs = iter(["9", "6"]) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) with pytest.raises(SystemExit): main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) @@ -77,7 +77,7 @@ def test_out_of_range_menu(monkeypatch, capsys): def test_invalid_add_entry_submenu(monkeypatch, capsys): called = {"add": False, "retrieve": False, "modify": False} pm, _ = _make_pm(called) - inputs = iter(["1", "4", "3", "5"]) + inputs = iter(["1", "4", "3", "6"]) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) with pytest.raises(SystemExit): @@ -92,7 +92,7 @@ def test_inactivity_timeout_loop(monkeypatch, capsys): pm, locked = _make_pm(called) pm.last_activity = 0 monkeypatch.setattr(time, "time", lambda: 100.0) - monkeypatch.setattr(main, "timed_input", lambda *_: "5") + monkeypatch.setattr(main, "timed_input", lambda *_: "6") with pytest.raises(SystemExit): main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1) out = capsys.readouterr().out diff --git a/src/tests/test_inactivity_lock.py b/src/tests/test_inactivity_lock.py index f819944..500bcd6 100644 --- a/src/tests/test_inactivity_lock.py +++ b/src/tests/test_inactivity_lock.py @@ -36,7 +36,7 @@ def test_inactivity_triggers_lock(monkeypatch): unlock_vault=unlock_vault, ) - monkeypatch.setattr(main, "timed_input", lambda *_: "5") + monkeypatch.setattr(main, "timed_input", lambda *_: "6") with pytest.raises(SystemExit): main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1) @@ -72,7 +72,7 @@ def test_input_timeout_triggers_lock(monkeypatch): unlock_vault=unlock_vault, ) - responses = iter([TimeoutError(), "5"]) + responses = iter([TimeoutError(), "6"]) def fake_input(*_args, **_kwargs): val = next(responses) From 51a515a4cc9b07e71f3e5f42623d4686c4647862 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 08:54:54 -0400 Subject: [PATCH 12/33] Fix TOTP display function --- src/password_manager/manager.py | 1 + src/tests/test_manager_display_totp_codes.py | 56 ++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 src/tests/test_manager_display_totp_codes.py diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 1f0fe2d..f51f32c 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -26,6 +26,7 @@ from password_manager.backup import BackupManager from password_manager.vault import Vault from password_manager.portable_backup import export_backup, import_backup from password_manager.totp import TotpManager +from password_manager.entry_types import EntryType from utils.key_derivation import ( derive_key_from_parent_seed, derive_key_from_password, diff --git a/src/tests/test_manager_display_totp_codes.py b/src/tests/test_manager_display_totp_codes.py new file mode 100644 index 0000000..544b4e1 --- /dev/null +++ b/src/tests/test_manager_display_totp_codes.py @@ -0,0 +1,56 @@ +import sys +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import create_vault, TEST_SEED, TEST_PASSWORD + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager +from password_manager.manager import PasswordManager, EncryptionMode + + +class FakeNostrClient: + def __init__(self, *args, **kwargs): + self.published = [] + + def publish_snapshot(self, data: bytes): + self.published.append(data) + return None, "abcd" + + +def test_handle_display_totp_codes(monkeypatch, capsys): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + entry_mgr = EntryManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.parent_seed = TEST_SEED + pm.nostr_client = FakeNostrClient() + pm.fingerprint_dir = tmp_path + pm.is_dirty = False + + entry_mgr.add_totp("Example", TEST_SEED) + + monkeypatch.setattr(pm.entry_manager, "get_totp_code", lambda *a, **k: "123456") + monkeypatch.setattr( + pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 30 + ) + + def interrupt(_): + raise KeyboardInterrupt() + + monkeypatch.setattr("password_manager.manager.time.sleep", interrupt) + + pm.handle_display_totp_codes() + out = capsys.readouterr().out + assert "Example" in out + assert "123456" in out From fd3986330f6dde7ff43d7ccf57035c46c10bcc42 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 09:20:02 -0400 Subject: [PATCH 13/33] Add navigation and indexing to TOTP display --- src/password_manager/manager.py | 47 ++++++++++++++------ src/tests/test_manager_add_totp.py | 4 +- src/tests/test_manager_display_totp_codes.py | 12 ++--- 3 files changed, 43 insertions(+), 20 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index f51f32c..0115289 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -17,6 +17,7 @@ import os from typing import Optional import shutil import time +import select from termcolor import colored from password_manager.encryption import EncryptionManager @@ -937,7 +938,7 @@ class PasswordManager: self.last_update = time.time() print( colored( - f"\nImported \u2714 Codes for {label} are now stored in SeedPass.", + f"\nImported \u2714 Codes for {label} are now stored in SeedPass at ID {entry_id}.", "green", ) ) @@ -1184,32 +1185,50 @@ class PasswordManager: try: data = self.entry_manager.vault.load_index() entries = data.get("entries", {}) - totp_list: list[tuple[str, int, int]] = [] + totp_list: list[tuple[str, int, int, bool]] = [] for idx_str, entry in entries.items(): if entry.get("type") == EntryType.TOTP.value: label = entry.get("label", "") period = int(entry.get("period", 30)) - totp_list.append((label, int(idx_str), period)) + imported = "secret" in entry + totp_list.append((label, int(idx_str), period, imported)) if not totp_list: print(colored("No 2FA entries found.", "yellow")) return totp_list.sort(key=lambda t: t[0].lower()) - - print(colored("Press Ctrl+C to return to the menu.", "cyan")) + print(colored("Press 'b' then Enter to return to the menu.", "cyan")) while True: print("\033c", end="") - for label, idx, period in totp_list: - code = self.entry_manager.get_totp_code(idx, self.parent_seed) - remaining = self.entry_manager.get_totp_time_remaining(idx) - filled = int(20 * (period - remaining) / period) - bar = "[" + "#" * filled + "-" * (20 - filled) + "]" - print(f"{label}: {code} {bar} {remaining:2d}s") + print(colored("Press 'b' then Enter to return to the menu.", "cyan")) + generated = [t for t in totp_list if not t[3]] + imported_list = [t for t in totp_list if t[3]] + if generated: + print(colored("\nGenerated 2FA Codes:", "green")) + for label, idx, period, _ in generated: + code = self.entry_manager.get_totp_code(idx, self.parent_seed) + remaining = self.entry_manager.get_totp_time_remaining(idx) + filled = int(20 * (period - remaining) / period) + bar = "[" + "#" * filled + "-" * (20 - filled) + "]" + print(f"[{idx}] {label}: {code} {bar} {remaining:2d}s") + if imported_list: + print(colored("\nImported 2FA Codes:", "green")) + for label, idx, period, _ in imported_list: + code = self.entry_manager.get_totp_code(idx, self.parent_seed) + remaining = self.entry_manager.get_totp_time_remaining(idx) + filled = int(20 * (period - remaining) / period) + bar = "[" + "#" * filled + "-" * (20 - filled) + "]" + print(f"[{idx}] {label}: {code} {bar} {remaining:2d}s") sys.stdout.flush() - time.sleep(1) - except KeyboardInterrupt: - print() + try: + if sys.stdin in select.select([sys.stdin], [], [], 1)[0]: + user_input = sys.stdin.readline().strip().lower() + if user_input == "b": + break + except KeyboardInterrupt: + print() + break except Exception as e: logging.error(f"Error displaying TOTP codes: {e}", exc_info=True) print(colored(f"Error: Failed to display TOTP codes: {e}", "red")) diff --git a/src/tests/test_manager_add_totp.py b/src/tests/test_manager_add_totp.py index 6ee18b9..f7ccdf6 100644 --- a/src/tests/test_manager_add_totp.py +++ b/src/tests/test_manager_add_totp.py @@ -21,7 +21,7 @@ class FakeNostrClient: return None, "abcd" -def test_handle_add_totp(monkeypatch): +def test_handle_add_totp(monkeypatch, capsys): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) @@ -51,6 +51,7 @@ def test_handle_add_totp(monkeypatch): monkeypatch.setattr(pm, "sync_vault", lambda: None) pm.handle_add_totp() + out = capsys.readouterr().out entry = entry_mgr.retrieve_entry(0) assert entry == { @@ -60,3 +61,4 @@ def test_handle_add_totp(monkeypatch): "period": 30, "digits": 6, } + assert "ID 0" in out diff --git a/src/tests/test_manager_display_totp_codes.py b/src/tests/test_manager_display_totp_codes.py index 544b4e1..0bd562a 100644 --- a/src/tests/test_manager_display_totp_codes.py +++ b/src/tests/test_manager_display_totp_codes.py @@ -45,12 +45,14 @@ def test_handle_display_totp_codes(monkeypatch, capsys): pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 30 ) - def interrupt(_): - raise KeyboardInterrupt() - - monkeypatch.setattr("password_manager.manager.time.sleep", interrupt) + # interrupt the loop after first iteration + monkeypatch.setattr( + "password_manager.manager.select.select", + lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()), + ) pm.handle_display_totp_codes() out = capsys.readouterr().out - assert "Example" in out + assert "Generated 2FA Codes" in out + assert "[0] Example" in out assert "123456" in out From 3364824fc44c67d99c8c5a285814ad86d2be2bbf Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 09:44:09 -0400 Subject: [PATCH 14/33] Add relay health check and tests --- src/constants.py | 1 + src/nostr/client.py | 37 +++++++++++++++++++++++++++++++++ src/password_manager/manager.py | 12 +++++++++++ src/tests/test_nostr_client.py | 16 ++++++++++++++ 4 files changed, 66 insertions(+) diff --git a/src/constants.py b/src/constants.py index 6a86868..dfcd0d1 100644 --- a/src/constants.py +++ b/src/constants.py @@ -11,6 +11,7 @@ logger = logging.getLogger(__name__) # ----------------------------------- MAX_RETRIES = 3 # Maximum number of retries for relay connections RETRY_DELAY = 5 # Seconds to wait before retrying a failed connection +MIN_HEALTHY_RELAYS = 2 # Minimum relays that should return data on startup # ----------------------------------- # Application Directory and Paths diff --git a/src/nostr/client.py b/src/nostr/client.py index 20fd2f3..2ff7019 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -8,6 +8,7 @@ from typing import List, Optional, Tuple import hashlib import asyncio import gzip +import websockets # Imports from the nostr-sdk library from nostr_sdk import ( @@ -137,6 +138,42 @@ class NostrClient: await self.client.connect() logger.info(f"NostrClient connected to relays: {self.relays}") + async def _ping_relay(self, relay: str, timeout: float) -> bool: + """Attempt to retrieve the latest event from a single relay.""" + sub_id = "seedpass-health" + pubkey = self.keys.public_key().to_hex() + req = json.dumps( + ["REQ", sub_id, {"kinds": [1], "authors": [pubkey], "limit": 1}] + ) + try: + async with websockets.connect( + relay, open_timeout=timeout, close_timeout=timeout + ) as ws: + await ws.send(req) + while True: + msg = await asyncio.wait_for(ws.recv(), timeout=timeout) + data = json.loads(msg) + if data[0] == "EVENT": + return True + if data[0] == "EOSE": + return False + except Exception: + return False + + async def _check_relay_health(self, min_relays: int, timeout: float) -> int: + tasks = [self._ping_relay(r, timeout) for r in self.relays] + results = await asyncio.gather(*tasks, return_exceptions=True) + healthy = sum(1 for r in results if r is True) + if healthy < min_relays: + logger.warning( + "Only %s relays responded with data; consider adding more.", healthy + ) + return healthy + + def check_relay_health(self, min_relays: int = 2, timeout: float = 5.0) -> int: + """Ping relays and return the count of those providing data.""" + return asyncio.run(self._check_relay_health(min_relays, timeout)) + def publish_json_to_nostr( self, encrypted_json: bytes, diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 0115289..76295ff 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -40,6 +40,7 @@ from utils.password_prompt import ( prompt_existing_password, confirm_action, ) +from constants import MIN_HEALTHY_RELAYS from constants import ( APP_DIR, @@ -763,6 +764,17 @@ class PasswordManager: parent_seed=getattr(self, "parent_seed", None), ) + if hasattr(self.nostr_client, "check_relay_health"): + healthy = self.nostr_client.check_relay_health(MIN_HEALTHY_RELAYS) + if healthy < MIN_HEALTHY_RELAYS: + print( + colored( + f"Only {healthy} relay(s) responded with your latest event." + " Consider adding more relays via Settings.", + "yellow", + ) + ) + logger.debug("Managers re-initialized for the new fingerprint.") except Exception as e: diff --git a/src/tests/test_nostr_client.py b/src/tests/test_nostr_client.py index fe58737..8a849b8 100644 --- a/src/tests/test_nostr_client.py +++ b/src/tests/test_nostr_client.py @@ -75,3 +75,19 @@ def test_initialize_client_pool_add_relay_fallback(tmp_path): fc = client.client assert fc.added == client.relays assert fc.connected is True + + +def test_check_relay_health_runs_async(tmp_path, monkeypatch): + client = _setup_client(tmp_path, FakeAddRelayClient) + + recorded = {} + + async def fake_check(min_relays, timeout): + recorded["args"] = (min_relays, timeout) + return 1 + + monkeypatch.setattr(client, "_check_relay_health", fake_check) + result = client.check_relay_health(3, timeout=2) + + assert result == 1 + assert recorded["args"] == (3, 2) From d40156c2051e6349d04cb03027c6dc198aa9b2de Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 09:58:37 -0400 Subject: [PATCH 15/33] Add in-memory secret encryption --- src/password_manager/manager.py | 19 ++++++++++++++++++- src/utils/__init__.py | 2 ++ src/utils/memory_protection.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 src/utils/memory_protection.py diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 76295ff..c71be79 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -40,6 +40,7 @@ from utils.password_prompt import ( prompt_existing_password, confirm_action, ) +from utils.memory_protection import InMemorySecret from constants import MIN_HEALTHY_RELAYS from constants import ( @@ -93,7 +94,7 @@ class PasswordManager: self.backup_manager: Optional[BackupManager] = None self.vault: Optional[Vault] = None self.fingerprint_manager: Optional[FingerprintManager] = None - self.parent_seed: Optional[str] = None + self._parent_seed_secret: Optional[InMemorySecret] = None self.bip85: Optional[BIP85] = None self.nostr_client: Optional[NostrClient] = None self.config_manager: Optional[ConfigManager] = None @@ -114,6 +115,22 @@ class PasswordManager: # Set the current fingerprint directory self.fingerprint_dir = self.fingerprint_manager.get_current_fingerprint_dir() + @property + def parent_seed(self) -> Optional[str]: + """Return the decrypted parent seed if set.""" + if self._parent_seed_secret is None: + return None + return self._parent_seed_secret.get_str() + + @parent_seed.setter + def parent_seed(self, value: Optional[str]) -> None: + if value is None: + if self._parent_seed_secret: + self._parent_seed_secret.wipe() + self._parent_seed_secret = None + else: + self._parent_seed_secret = InMemorySecret(value.encode("utf-8")) + def update_activity(self) -> None: """Record the current time as the last user activity.""" self.last_activity = time.time() diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 5a96481..95a731d 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -24,6 +24,7 @@ try: ) from .password_prompt import prompt_for_password from .input_utils import timed_input + from .memory_protection import InMemorySecret if logger.isEnabledFor(logging.DEBUG): logger.info("Modules imported successfully.") @@ -47,4 +48,5 @@ __all__ = [ "shared_lock", "prompt_for_password", "timed_input", + "InMemorySecret", ] diff --git a/src/utils/memory_protection.py b/src/utils/memory_protection.py new file mode 100644 index 0000000..cc604c1 --- /dev/null +++ b/src/utils/memory_protection.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import os +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + +class InMemorySecret: + """Store sensitive data encrypted in RAM using AES-GCM.""" + + def __init__(self, data: bytes) -> None: + if not isinstance(data, (bytes, bytearray)): + raise TypeError("data must be bytes") + self._key = AESGCM.generate_key(bit_length=128) + self._nonce = os.urandom(12) + self._cipher = AESGCM(self._key) + self._encrypted = self._cipher.encrypt(self._nonce, bytes(data), None) + + def get_bytes(self) -> bytes: + """Decrypt and return the plaintext bytes.""" + return self._cipher.decrypt(self._nonce, self._encrypted, None) + + def wipe(self) -> None: + """Zero out internal data.""" + self._key = None + self._nonce = None + self._cipher = None + self._encrypted = None + + def get_str(self) -> str: + """Return the decrypted plaintext as a UTF-8 string.""" + return self.get_bytes().decode("utf-8") From fe41ae4a60dbb49917b0e11e7f5899248287a9cf Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 10:08:35 -0400 Subject: [PATCH 16/33] Handle retrieval of 2FA entries --- src/password_manager/manager.py | 26 +++++++++---- src/tests/test_manager_retrieve_totp.py | 50 +++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 src/tests/test_manager_retrieve_totp.py diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index c71be79..de3b3cc 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1003,21 +1003,35 @@ class PasswordManager: return index = int(index_input) - # Retrieve entry details entry = self.entry_manager.retrieve_entry(index) if not entry: return - # Display entry details + entry_type = entry.get("type", EntryType.PASSWORD.value) + + if entry_type == EntryType.TOTP.value: + label = entry.get("label", "") + period = int(entry.get("period", 30)) + notes = entry.get("notes", "") + print(colored(f"Retrieving 2FA code for '{label}'.", "cyan")) + try: + code = self.entry_manager.get_totp_code(index, self.parent_seed) + print(colored("\n[+] Retrieved 2FA Code:\n", "green")) + print(colored(f"Code: {code}", "yellow")) + if notes: + print(colored(f"Notes: {notes}", "cyan")) + TotpManager.print_progress_bar(period) + except Exception as e: + logging.error(f"Error generating TOTP code: {e}", exc_info=True) + print(colored(f"Error: Failed to generate TOTP code: {e}", "red")) + return + website_name = entry.get("website") length = entry.get("length") username = entry.get("username") url = entry.get("url") blacklisted = entry.get("blacklisted") notes = entry.get("notes", "") - notes = entry.get("notes", "") - notes = entry.get("notes", "") - notes = entry.get("notes", "") print( colored( @@ -1037,10 +1051,8 @@ class PasswordManager: ) ) - # Generate the password password = self.password_generator.generate_password(length, index) - # Display the password and associated details if password: print( colored(f"\n[+] Retrieved Password for {website_name}:\n", "green") diff --git a/src/tests/test_manager_retrieve_totp.py b/src/tests/test_manager_retrieve_totp.py new file mode 100644 index 0000000..ea10084 --- /dev/null +++ b/src/tests/test_manager_retrieve_totp.py @@ -0,0 +1,50 @@ +import sys +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import create_vault, TEST_SEED, TEST_PASSWORD + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager +from password_manager.manager import PasswordManager, EncryptionMode, TotpManager + + +class FakeNostrClient: + def __init__(self, *args, **kwargs): + self.published = [] + + def publish_snapshot(self, data: bytes): + self.published.append(data) + return None, "abcd" + + +def test_handle_retrieve_totp_entry(monkeypatch, capsys): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + entry_mgr = EntryManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.parent_seed = TEST_SEED + pm.nostr_client = FakeNostrClient() + pm.fingerprint_dir = tmp_path + pm.is_dirty = False + + entry_mgr.add_totp("Example", TEST_SEED) + + monkeypatch.setattr("builtins.input", lambda *a, **k: "0") + monkeypatch.setattr(pm.entry_manager, "get_totp_code", lambda *a, **k: "123456") + monkeypatch.setattr(TotpManager, "print_progress_bar", lambda period: None) + + pm.handle_retrieve_entry() + out = capsys.readouterr().out + assert "Retrieved 2FA Code" in out + assert "123456" in out From 3d65800eb219a54506025460683ad0ce1986573d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 10:17:42 -0400 Subject: [PATCH 17/33] Keep TOTP retrieval active --- src/password_manager/manager.py | 23 +++++++++++++++++------ src/tests/test_manager_retrieve_totp.py | 4 ++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index de3b3cc..a3cc557 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1014,13 +1014,24 @@ class PasswordManager: period = int(entry.get("period", 30)) notes = entry.get("notes", "") print(colored(f"Retrieving 2FA code for '{label}'.", "cyan")) + print(colored("Press 'b' then Enter to return to the menu.", "cyan")) try: - code = self.entry_manager.get_totp_code(index, self.parent_seed) - print(colored("\n[+] Retrieved 2FA Code:\n", "green")) - print(colored(f"Code: {code}", "yellow")) - if notes: - print(colored(f"Notes: {notes}", "cyan")) - TotpManager.print_progress_bar(period) + while True: + code = self.entry_manager.get_totp_code(index, self.parent_seed) + print(colored("\n[+] Retrieved 2FA Code:\n", "green")) + print(colored(f"Label: {label}", "cyan")) + print(colored(f"Code: {code}", "yellow")) + if notes: + print(colored(f"Notes: {notes}", "cyan")) + TotpManager.print_progress_bar(period) + try: + if sys.stdin in select.select([sys.stdin], [], [], 0)[0]: + user_input = sys.stdin.readline().strip().lower() + if user_input == "b": + break + except KeyboardInterrupt: + print() + break except Exception as e: logging.error(f"Error generating TOTP code: {e}", exc_info=True) print(colored(f"Error: Failed to generate TOTP code: {e}", "red")) diff --git a/src/tests/test_manager_retrieve_totp.py b/src/tests/test_manager_retrieve_totp.py index ea10084..09c3824 100644 --- a/src/tests/test_manager_retrieve_totp.py +++ b/src/tests/test_manager_retrieve_totp.py @@ -43,6 +43,10 @@ def test_handle_retrieve_totp_entry(monkeypatch, capsys): monkeypatch.setattr("builtins.input", lambda *a, **k: "0") monkeypatch.setattr(pm.entry_manager, "get_totp_code", lambda *a, **k: "123456") monkeypatch.setattr(TotpManager, "print_progress_bar", lambda period: None) + monkeypatch.setattr( + "password_manager.manager.select.select", + lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()), + ) pm.handle_retrieve_entry() out = capsys.readouterr().out From 6a98df4e564f453b851718524000151db13937ad Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 10:31:06 -0400 Subject: [PATCH 18/33] Add TOTP export option --- src/main.py | 13 ++++--- src/password_manager/manager.py | 58 +++++++++++++++++++++++++++++ src/tests/test_export_totp_codes.py | 55 +++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 src/tests/test_export_totp_codes.py diff --git a/src/main.py b/src/main.py index cecfd63..14aedc5 100644 --- a/src/main.py +++ b/src/main.py @@ -503,9 +503,10 @@ def handle_settings(password_manager: PasswordManager) -> None: print("5. Backup Parent Seed") print("6. Export database") print("7. Import database") - print("8. Set inactivity timeout") - print("9. Lock Vault") - print("10. Back") + print("8. Export 2FA codes") + print("9. Set inactivity timeout") + print("10. Lock Vault") + print("11. Back") choice = input("Select an option: ").strip() if choice == "1": handle_profiles_menu(password_manager) @@ -524,12 +525,14 @@ def handle_settings(password_manager: PasswordManager) -> None: if path: password_manager.handle_import_database(Path(path)) elif choice == "8": - handle_set_inactivity_timeout(password_manager) + password_manager.handle_export_totp_codes() elif choice == "9": + handle_set_inactivity_timeout(password_manager) + elif choice == "10": password_manager.lock_vault() print(colored("Vault locked. Please re-enter your password.", "yellow")) password_manager.unlock_vault() - elif choice == "10": + elif choice == "11": break else: print(colored("Invalid choice.", "red")) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index a3cc557..473a51f 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -38,6 +38,7 @@ from utils.checksum import calculate_checksum, verify_checksum from utils.password_prompt import ( prompt_for_password, prompt_existing_password, + prompt_new_password, confirm_action, ) from utils.memory_protection import InMemorySecret @@ -1440,6 +1441,63 @@ class PasswordManager: logging.error(f"Failed to import database: {e}", exc_info=True) print(colored(f"Error: Failed to import database: {e}", "red")) + def handle_export_totp_codes(self) -> Path | None: + """Export all 2FA codes to a JSON file for other authenticator apps.""" + try: + data = self.entry_manager.vault.load_index() + entries = data.get("entries", {}) + + totp_entries = [] + for entry in entries.values(): + if entry.get("type") == EntryType.TOTP.value: + label = entry.get("label", "") + period = int(entry.get("period", 30)) + digits = int(entry.get("digits", 6)) + if "secret" in entry: + secret = entry["secret"] + else: + idx = int(entry.get("index", 0)) + secret = TotpManager.derive_secret(self.parent_seed, idx) + uri = TotpManager.make_otpauth_uri(label, secret, period, digits) + totp_entries.append( + { + "label": label, + "secret": secret, + "period": period, + "digits": digits, + "uri": uri, + } + ) + + if not totp_entries: + print(colored("No 2FA codes to export.", "yellow")) + return None + + dest_str = input( + "Enter destination file path (default: totp_export.json): " + ).strip() + dest = Path(dest_str) if dest_str else Path("totp_export.json") + + json_data = json.dumps({"entries": totp_entries}, indent=2) + + if confirm_action("Encrypt export with a password? (Y/N): "): + password = prompt_new_password() + key = derive_key_from_password(password) + enc_mgr = EncryptionManager(key, dest.parent) + data_bytes = enc_mgr.encrypt_data(json_data.encode("utf-8")) + dest = dest.with_suffix(dest.suffix + ".enc") + dest.write_bytes(data_bytes) + else: + dest.write_text(json_data) + + os.chmod(dest, 0o600) + print(colored(f"2FA codes exported to '{dest}'.", "green")) + return dest + except Exception as e: + logging.error(f"Failed to export TOTP codes: {e}", exc_info=True) + print(colored(f"Error: Failed to export 2FA codes: {e}", "red")) + return None + def handle_backup_reveal_parent_seed(self) -> None: """ Handles the backup and reveal of the parent seed. diff --git a/src/tests/test_export_totp_codes.py b/src/tests/test_export_totp_codes.py new file mode 100644 index 0000000..0c40936 --- /dev/null +++ b/src/tests/test_export_totp_codes.py @@ -0,0 +1,55 @@ +import sys +from pathlib import Path +from tempfile import TemporaryDirectory + +import json + +from helpers import create_vault, TEST_SEED, TEST_PASSWORD + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager +from password_manager.manager import PasswordManager, EncryptionMode +from password_manager.totp import TotpManager + + +class FakeNostrClient: + def publish_snapshot(self, data: bytes): + return None, "abcd" + + +def test_handle_export_totp_codes(monkeypatch, tmp_path): + vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + entry_mgr = EntryManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.parent_seed = TEST_SEED + pm.nostr_client = FakeNostrClient() + pm.fingerprint_dir = tmp_path + + # add totp entries + entry_mgr.add_totp("Example", TEST_SEED) + entry_mgr.add_totp("Imported", TEST_SEED, secret="JBSWY3DPEHPK3PXP") + + export_path = tmp_path / "out.json" + monkeypatch.setattr("builtins.input", lambda *a, **k: str(export_path)) + monkeypatch.setattr( + "password_manager.manager.confirm_action", lambda *_a, **_k: False + ) + + pm.handle_export_totp_codes() + + data = json.loads(export_path.read_text()) + assert len(data["entries"]) == 2 + labels = {e["label"] for e in data["entries"]} + assert {"Example", "Imported"} == labels + # check URI format + uri = data["entries"][0]["uri"] + assert uri.startswith("otpauth://totp/") From ef4a9966d19c56ee8b5dc56ec8b2415ee0d71b51 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 10:40:47 -0400 Subject: [PATCH 19/33] fix totp blacklist and exit --- src/password_manager/manager.py | 36 ++++++++++++++----- src/tests/test_manager_display_totp_codes.py | 38 ++++++++++++++++++++ src/tests/test_manager_retrieve_totp.py | 8 +++-- 3 files changed, 71 insertions(+), 11 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 473a51f..07c6156 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1024,14 +1024,30 @@ class PasswordManager: print(colored(f"Code: {code}", "yellow")) if notes: print(colored(f"Notes: {notes}", "cyan")) - TotpManager.print_progress_bar(period) - try: - if sys.stdin in select.select([sys.stdin], [], [], 0)[0]: - user_input = sys.stdin.readline().strip().lower() - if user_input == "b": - break - except KeyboardInterrupt: - print() + remaining = self.entry_manager.get_totp_time_remaining(index) + exit_loop = False + while remaining > 0: + filled = int(20 * (period - remaining) / period) + bar = "[" + "#" * filled + "-" * (20 - filled) + "]" + sys.stdout.write(f"\r{bar} {remaining:2d}s") + sys.stdout.flush() + try: + if ( + sys.stdin + in select.select([sys.stdin], [], [], 1)[0] + ): + user_input = sys.stdin.readline().strip().lower() + if user_input == "b": + exit_loop = True + break + except KeyboardInterrupt: + exit_loop = True + print() + break + remaining -= 1 + sys.stdout.write("\n") + sys.stdout.flush() + if exit_loop: break except Exception as e: logging.error(f"Error generating TOTP code: {e}", exc_info=True) @@ -1240,7 +1256,9 @@ class PasswordManager: entries = data.get("entries", {}) totp_list: list[tuple[str, int, int, bool]] = [] for idx_str, entry in entries.items(): - if entry.get("type") == EntryType.TOTP.value: + if entry.get("type") == EntryType.TOTP.value and not entry.get( + "blacklisted", False + ): label = entry.get("label", "") period = int(entry.get("period", 30)) imported = "secret" in entry diff --git a/src/tests/test_manager_display_totp_codes.py b/src/tests/test_manager_display_totp_codes.py index 0bd562a..b462c36 100644 --- a/src/tests/test_manager_display_totp_codes.py +++ b/src/tests/test_manager_display_totp_codes.py @@ -56,3 +56,41 @@ def test_handle_display_totp_codes(monkeypatch, capsys): assert "Generated 2FA Codes" in out assert "[0] Example" in out assert "123456" in out + + +def test_display_totp_codes_excludes_blacklisted(monkeypatch, capsys): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + entry_mgr = EntryManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.parent_seed = TEST_SEED + pm.nostr_client = FakeNostrClient() + pm.fingerprint_dir = tmp_path + pm.is_dirty = False + + entry_mgr.add_totp("Visible", TEST_SEED) + entry_mgr.add_totp("Hidden", TEST_SEED) + entry_mgr.modify_entry(1, blacklisted=True) + + monkeypatch.setattr(pm.entry_manager, "get_totp_code", lambda *a, **k: "123456") + monkeypatch.setattr( + pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 30 + ) + + monkeypatch.setattr( + "password_manager.manager.select.select", + lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + pm.handle_display_totp_codes() + out = capsys.readouterr().out + assert "Visible" in out + assert "Hidden" not in out diff --git a/src/tests/test_manager_retrieve_totp.py b/src/tests/test_manager_retrieve_totp.py index 09c3824..c29b6a3 100644 --- a/src/tests/test_manager_retrieve_totp.py +++ b/src/tests/test_manager_retrieve_totp.py @@ -42,10 +42,14 @@ def test_handle_retrieve_totp_entry(monkeypatch, capsys): monkeypatch.setattr("builtins.input", lambda *a, **k: "0") monkeypatch.setattr(pm.entry_manager, "get_totp_code", lambda *a, **k: "123456") - monkeypatch.setattr(TotpManager, "print_progress_bar", lambda period: None) + monkeypatch.setattr( + pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1 + ) + monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) + monkeypatch.setattr(sys.stdin, "readline", lambda *a, **k: "b\n") monkeypatch.setattr( "password_manager.manager.select.select", - lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()), + lambda *a, **k: ([sys.stdin], [], []), ) pm.handle_retrieve_entry() From 3cde17bd5a36b800ee8d6b35be883d4e1078da08 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 11:12:13 -0400 Subject: [PATCH 20/33] Add additional backup path config --- src/password_manager/config_manager.py | 14 ++++++++++++++ src/tests/test_config_manager.py | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/password_manager/config_manager.py b/src/password_manager/config_manager.py index 5d9d620..7c02aae 100644 --- a/src/password_manager/config_manager.py +++ b/src/password_manager/config_manager.py @@ -44,6 +44,7 @@ class ConfigManager: "pin_hash": "", "password_hash": "", "inactivity_timeout": INACTIVITY_TIMEOUT, + "additional_backup_path": "", } try: data = self.vault.load_config() @@ -54,6 +55,7 @@ class ConfigManager: data.setdefault("pin_hash", "") data.setdefault("password_hash", "") data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT) + data.setdefault("additional_backup_path", "") # Migrate legacy hashed_password.enc if present and password_hash is missing legacy_file = self.fingerprint_dir / "hashed_password.enc" @@ -130,3 +132,15 @@ class ConfigManager: """Retrieve the inactivity timeout setting in seconds.""" config = self.load_config(require_pin=False) return float(config.get("inactivity_timeout", INACTIVITY_TIMEOUT)) + + def set_additional_backup_path(self, path: Optional[str]) -> None: + """Persist an optional additional backup path in the config.""" + config = self.load_config(require_pin=False) + config["additional_backup_path"] = path or "" + self.save_config(config) + + def get_additional_backup_path(self) -> Optional[str]: + """Retrieve the additional backup path if configured.""" + config = self.load_config(require_pin=False) + value = config.get("additional_backup_path", "") + return value or None diff --git a/src/tests/test_config_manager.py b/src/tests/test_config_manager.py index 92f3f31..5be4603 100644 --- a/src/tests/test_config_manager.py +++ b/src/tests/test_config_manager.py @@ -22,6 +22,7 @@ def test_config_defaults_and_round_trip(): assert cfg["relays"] == list(DEFAULT_RELAYS) assert cfg["pin_hash"] == "" assert cfg["password_hash"] == "" + assert cfg["additional_backup_path"] == "" cfg_mgr.set_pin("1234") cfg_mgr.set_relays(["wss://example.com"], require_pin=False) @@ -111,3 +112,21 @@ def test_password_hash_migrates_from_file(tmp_path): (tmp_path / "hashed_password.enc").unlink() cfg2 = cfg_mgr.load_config(require_pin=False) assert cfg2["password_hash"] == hashed.decode() + + +def test_additional_backup_path_round_trip(): + with TemporaryDirectory() as tmpdir: + vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, Path(tmpdir)) + + # default should be empty string + assert cfg_mgr.load_config(require_pin=False)["additional_backup_path"] == "" + + cfg_mgr.set_additional_backup_path("/tmp/my_backups") + cfg = cfg_mgr.load_config(require_pin=False) + assert cfg["additional_backup_path"] == "/tmp/my_backups" + assert cfg_mgr.get_additional_backup_path() == "/tmp/my_backups" + + cfg_mgr.set_additional_backup_path(None) + cfg2 = cfg_mgr.load_config(require_pin=False) + assert cfg2["additional_backup_path"] == "" From 6691667bb1b553230c9ce6775e9d305bae6aa1f9 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 11:35:12 -0400 Subject: [PATCH 21/33] Refactor EntryManager to use BackupManager --- examples/entry_management_demo.py | 4 +- src/password_manager/entry_management.py | 59 +++++-------------- src/password_manager/manager.py | 5 +- src/tests/test_entries_empty.py | 4 +- src/tests/test_entry_add.py | 10 +++- .../test_entry_management_checksum_path.py | 12 ++-- src/tests/test_export_totp_codes.py | 2 +- src/tests/test_manager_add_totp.py | 2 +- src/tests/test_manager_display_totp_codes.py | 4 +- src/tests/test_manager_retrieve_totp.py | 2 +- src/tests/test_manager_workflow.py | 4 +- src/tests/test_nostr_backup.py | 6 +- src/tests/test_nostr_dummy_client.py | 4 +- src/tests/test_nostr_index_size.py | 4 +- src/tests/test_password_change.py | 4 +- .../test_password_unlock_after_change.py | 4 +- src/tests/test_profile_management.py | 4 +- src/tests/test_totp_entry.py | 10 +++- 18 files changed, 70 insertions(+), 74 deletions(-) diff --git a/examples/entry_management_demo.py b/examples/entry_management_demo.py index 030187d..d5a29d8 100644 --- a/examples/entry_management_demo.py +++ b/examples/entry_management_demo.py @@ -4,6 +4,7 @@ from cryptography.fernet import Fernet from password_manager.encryption import EncryptionManager from password_manager.vault import Vault from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager from constants import initialize_app @@ -13,7 +14,8 @@ def main() -> None: key = Fernet.generate_key() enc = EncryptionManager(key, Path(".")) vault = Vault(enc, Path(".")) - manager = EntryManager(vault, Path(".")) + backup_mgr = BackupManager(Path(".")) + manager = EntryManager(vault, backup_mgr) index = manager.add_entry( "Example Website", diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index 87285ca..95539e5 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -19,10 +19,7 @@ import json import logging import hashlib import sys -import os import shutil -import time -import traceback from typing import Optional, Tuple, Dict, Any, List from pathlib import Path @@ -32,7 +29,7 @@ from password_manager.entry_types import EntryType from password_manager.totp import TotpManager from password_manager.vault import Vault -from utils.file_lock import exclusive_lock +from password_manager.backup import BackupManager # Instantiate the logger @@ -40,15 +37,16 @@ logger = logging.getLogger(__name__) class EntryManager: - def __init__(self, vault: Vault, fingerprint_dir: Path): - """ - Initializes the EntryManager with the EncryptionManager and fingerprint directory. + def __init__(self, vault: Vault, backup_manager: BackupManager): + """Initialize the EntryManager. - :param vault: The Vault instance for file access. - :param fingerprint_dir: The directory corresponding to the fingerprint. + Parameters: + vault: The Vault instance for file access. + backup_manager: Manages creation of entry database backups. """ self.vault = vault - self.fingerprint_dir = fingerprint_dir + self.backup_manager = backup_manager + self.fingerprint_dir = backup_manager.fingerprint_dir # Use paths relative to the fingerprint directory self.index_file = self.fingerprint_dir / "seedpass_entries_db.json.enc" @@ -141,7 +139,7 @@ class EntryManager: self._save_index(data) self.update_checksum() - self.backup_index_file() + self.backup_manager.create_backup() logger.info(f"Entry added successfully at index {index}.") print(colored(f"[+] Entry added successfully at index {index}.", "green")) @@ -203,7 +201,7 @@ class EntryManager: self._save_index(data) self.update_checksum() - self.backup_index_file() + self.backup_manager.create_backup() try: return TotpManager.make_otpauth_uri(label, secret, period, digits) @@ -219,7 +217,7 @@ class EntryManager: data["entries"][str(index)] = {"type": EntryType.SSH.value, "notes": notes} self._save_index(data) self.update_checksum() - self.backup_index_file() + self.backup_manager.create_backup() raise NotImplementedError("SSH key entry support not implemented yet") def add_seed(self, notes: str = "") -> int: @@ -230,7 +228,7 @@ class EntryManager: data["entries"][str(index)] = {"type": EntryType.SEED.value, "notes": notes} self._save_index(data) self.update_checksum() - self.backup_index_file() + self.backup_manager.create_backup() raise NotImplementedError("Seed entry support not implemented yet") def get_totp_code( @@ -355,7 +353,7 @@ class EntryManager: self._save_index(data) self.update_checksum() - self.backup_index_file() + self.backup_manager.create_backup() logger.info(f"Entry at index {index} modified successfully.") print( @@ -445,7 +443,7 @@ class EntryManager: logger.debug(f"Deleted entry at index {index}.") self.vault.save_index(data) self.update_checksum() - self.backup_index_file() + self.backup_manager.create_backup() logger.info(f"Entry at index {index} deleted successfully.") print( colored( @@ -491,35 +489,6 @@ class EntryManager: logger.error(f"Failed to update checksum: {e}", exc_info=True) print(colored(f"Error: Failed to update checksum: {e}", "red")) - def backup_index_file(self) -> None: - """ - Creates a backup of the encrypted JSON index file to prevent data loss. - """ - try: - # self.index_file already includes the fingerprint directory - index_file_path = self.index_file - if not index_file_path.exists(): - logger.warning( - f"Index file '{index_file_path}' does not exist. No backup created." - ) - return - - timestamp = int(time.time()) - backup_filename = f"entries_db_backup_{timestamp}.json.enc" - backup_path = self.fingerprint_dir / backup_filename - - with open(index_file_path, "rb") as original_file, open( - backup_path, "wb" - ) as backup_file: - shutil.copyfileobj(original_file, backup_file) - - logger.debug(f"Backup created at '{backup_path}'.") - print(colored(f"[+] Backup created at '{backup_path}'.", "green")) - - except Exception as e: - logger.error(f"Failed to create backup: {e}", exc_info=True) - print(colored(f"Warning: Failed to create backup: {e}", "yellow")) - def restore_from_backup(self, backup_path: str) -> None: """ Restores the index file from a specified backup file. diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 07c6156..0d6afe2 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -751,9 +751,10 @@ class PasswordManager: raise ValueError("EncryptionManager is not initialized.") # Reinitialize the managers with the updated EncryptionManager and current fingerprint context + self.backup_manager = BackupManager(fingerprint_dir=self.fingerprint_dir) self.entry_manager = EntryManager( vault=self.vault, - fingerprint_dir=self.fingerprint_dir, + backup_manager=self.backup_manager, ) self.password_generator = PasswordGenerator( @@ -762,8 +763,6 @@ class PasswordManager: bip85=self.bip85, ) - self.backup_manager = BackupManager(fingerprint_dir=self.fingerprint_dir) - # Load relay configuration and initialize NostrClient self.config_manager = ConfigManager( vault=self.vault, diff --git a/src/tests/test_entries_empty.py b/src/tests/test_entries_empty.py index 4c466b5..b3ac16e 100644 --- a/src/tests/test_entries_empty.py +++ b/src/tests/test_entries_empty.py @@ -6,13 +6,15 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager from password_manager.vault import Vault def test_list_entries_empty(): with TemporaryDirectory() as tmpdir: vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) - entry_mgr = EntryManager(vault, Path(tmpdir)) + backup_mgr = BackupManager(Path(tmpdir)) + entry_mgr = EntryManager(vault, backup_mgr) entries = entry_mgr.list_entries() assert entries == [] diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py index 71731b8..f5cb1f4 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -9,13 +9,15 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager from password_manager.vault import Vault def test_add_and_retrieve_entry(): with TemporaryDirectory() as tmpdir: vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) - entry_mgr = EntryManager(vault, Path(tmpdir)) + backup_mgr = BackupManager(Path(tmpdir)) + entry_mgr = EntryManager(vault, backup_mgr) index = entry_mgr.add_entry("example.com", 12, "user") entry = entry_mgr.retrieve_entry(index) @@ -47,7 +49,8 @@ def test_add_and_retrieve_entry(): def test_round_trip_entry_types(method, expected_type): with TemporaryDirectory() as tmpdir: vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) - entry_mgr = EntryManager(vault, Path(tmpdir)) + backup_mgr = BackupManager(Path(tmpdir)) + entry_mgr = EntryManager(vault, backup_mgr) if method == "add_entry": index = entry_mgr.add_entry("example.com", 8) @@ -68,7 +71,8 @@ def test_round_trip_entry_types(method, expected_type): def test_legacy_entry_defaults_to_password(): with TemporaryDirectory() as tmpdir: vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) - entry_mgr = EntryManager(vault, Path(tmpdir)) + backup_mgr = BackupManager(Path(tmpdir)) + entry_mgr = EntryManager(vault, backup_mgr) index = entry_mgr.add_entry("example.com", 8) diff --git a/src/tests/test_entry_management_checksum_path.py b/src/tests/test_entry_management_checksum_path.py index c002008..05e8d97 100644 --- a/src/tests/test_entry_management_checksum_path.py +++ b/src/tests/test_entry_management_checksum_path.py @@ -6,6 +6,7 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager from password_manager.vault import Vault @@ -13,7 +14,8 @@ def test_update_checksum_writes_to_expected_path(): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) - entry_mgr = EntryManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path) + entry_mgr = EntryManager(vault, backup_mgr) # create an empty index file vault.save_index({"entries": {}}) @@ -27,10 +29,12 @@ def test_backup_index_file_creates_backup_in_directory(): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) - entry_mgr = EntryManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path) + entry_mgr = EntryManager(vault, backup_mgr) vault.save_index({"entries": {}}) - entry_mgr.backup_index_file() + entry_mgr.backup_manager.create_backup() - backups = list(tmp_path.glob("entries_db_backup_*.json.enc")) + backup_dir = tmp_path / "backups" + backups = list(backup_dir.glob("entries_db_backup_*.json.enc")) assert len(backups) == 1 diff --git a/src/tests/test_export_totp_codes.py b/src/tests/test_export_totp_codes.py index 0c40936..5250d28 100644 --- a/src/tests/test_export_totp_codes.py +++ b/src/tests/test_export_totp_codes.py @@ -21,8 +21,8 @@ class FakeNostrClient: def test_handle_export_totp_codes(monkeypatch, tmp_path): vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) - entry_mgr = EntryManager(vault, tmp_path) backup_mgr = BackupManager(tmp_path) + entry_mgr = EntryManager(vault, backup_mgr) pm = PasswordManager.__new__(PasswordManager) pm.encryption_mode = EncryptionMode.SEED_ONLY diff --git a/src/tests/test_manager_add_totp.py b/src/tests/test_manager_add_totp.py index f7ccdf6..0a1e102 100644 --- a/src/tests/test_manager_add_totp.py +++ b/src/tests/test_manager_add_totp.py @@ -25,8 +25,8 @@ def test_handle_add_totp(monkeypatch, capsys): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) - entry_mgr = EntryManager(vault, tmp_path) backup_mgr = BackupManager(tmp_path) + entry_mgr = EntryManager(vault, backup_mgr) pm = PasswordManager.__new__(PasswordManager) pm.encryption_mode = EncryptionMode.SEED_ONLY diff --git a/src/tests/test_manager_display_totp_codes.py b/src/tests/test_manager_display_totp_codes.py index b462c36..0c52bfe 100644 --- a/src/tests/test_manager_display_totp_codes.py +++ b/src/tests/test_manager_display_totp_codes.py @@ -24,8 +24,8 @@ def test_handle_display_totp_codes(monkeypatch, capsys): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) - entry_mgr = EntryManager(vault, tmp_path) backup_mgr = BackupManager(tmp_path) + entry_mgr = EntryManager(vault, backup_mgr) pm = PasswordManager.__new__(PasswordManager) pm.encryption_mode = EncryptionMode.SEED_ONLY @@ -62,8 +62,8 @@ def test_display_totp_codes_excludes_blacklisted(monkeypatch, capsys): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) - entry_mgr = EntryManager(vault, tmp_path) backup_mgr = BackupManager(tmp_path) + entry_mgr = EntryManager(vault, backup_mgr) pm = PasswordManager.__new__(PasswordManager) pm.encryption_mode = EncryptionMode.SEED_ONLY diff --git a/src/tests/test_manager_retrieve_totp.py b/src/tests/test_manager_retrieve_totp.py index c29b6a3..4b28bfc 100644 --- a/src/tests/test_manager_retrieve_totp.py +++ b/src/tests/test_manager_retrieve_totp.py @@ -24,8 +24,8 @@ def test_handle_retrieve_totp_entry(monkeypatch, capsys): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) - entry_mgr = EntryManager(vault, tmp_path) backup_mgr = BackupManager(tmp_path) + entry_mgr = EntryManager(vault, backup_mgr) pm = PasswordManager.__new__(PasswordManager) pm.encryption_mode = EncryptionMode.SEED_ONLY diff --git a/src/tests/test_manager_workflow.py b/src/tests/test_manager_workflow.py index d78d43a..8d00a8c 100644 --- a/src/tests/test_manager_workflow.py +++ b/src/tests/test_manager_workflow.py @@ -29,8 +29,8 @@ def test_manager_workflow(monkeypatch): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) - entry_mgr = EntryManager(vault, tmp_path) backup_mgr = BackupManager(tmp_path) + entry_mgr = EntryManager(vault, backup_mgr) monkeypatch.setattr("password_manager.manager.NostrClient", FakeNostrClient) @@ -64,7 +64,7 @@ def test_manager_workflow(monkeypatch): pm.handle_add_password() assert pm.is_dirty is False - backups = list(tmp_path.glob("entries_db_backup_*.json.enc")) + backups = list((tmp_path / "backups").glob("entries_db_backup_*.json.enc")) assert len(backups) == 1 checksum_file = tmp_path / "seedpass_entries_db_checksum.txt" assert checksum_file.exists() diff --git a/src/tests/test_nostr_backup.py b/src/tests/test_nostr_backup.py index 0ab10f8..0d0ab9d 100644 --- a/src/tests/test_nostr_backup.py +++ b/src/tests/test_nostr_backup.py @@ -8,6 +8,7 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager from password_manager.vault import Vault from nostr.client import NostrClient @@ -16,7 +17,8 @@ def test_backup_and_publish_to_nostr(): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) - entry_mgr = EntryManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path) + entry_mgr = EntryManager(vault, backup_mgr) # create an index by adding an entry entry_mgr.add_entry("example.com", 12) @@ -34,7 +36,7 @@ def test_backup_and_publish_to_nostr(): enc_mgr, "decrypt_parent_seed", return_value="seed" ): nostr_client = NostrClient(enc_mgr, "fp") - entry_mgr.backup_index_file() + entry_mgr.backup_manager.create_backup() result = asyncio.run(nostr_client.publish_snapshot(encrypted_index)) mock_publish.assert_awaited_with(encrypted_index) diff --git a/src/tests/test_nostr_dummy_client.py b/src/tests/test_nostr_dummy_client.py index fe4e998..0f94836 100644 --- a/src/tests/test_nostr_dummy_client.py +++ b/src/tests/test_nostr_dummy_client.py @@ -4,12 +4,14 @@ import math from helpers import create_vault, dummy_nostr_client from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager from nostr.client import prepare_snapshot def test_manifest_generation(tmp_path): vault, enc_mgr = create_vault(tmp_path) - entry_mgr = EntryManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path) + entry_mgr = EntryManager(vault, backup_mgr) entry_mgr.add_entry("example.com", 12) entry_mgr.add_entry("test.com", 12) encrypted = vault.get_encrypted_index() diff --git a/src/tests/test_nostr_index_size.py b/src/tests/test_nostr_index_size.py index 00dc430..7860cad 100644 --- a/src/tests/test_nostr_index_size.py +++ b/src/tests/test_nostr_index_size.py @@ -16,6 +16,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.encryption import EncryptionManager from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager from password_manager.vault import Vault from nostr.client import NostrClient, Kind, KindStandard @@ -40,7 +41,8 @@ def test_nostr_index_size_limits(): ) npub = client.key_manager.get_npub() vault = Vault(enc_mgr, tmpdir) - entry_mgr = EntryManager(vault, Path(tmpdir)) + backup_mgr = BackupManager(Path(tmpdir)) + entry_mgr = EntryManager(vault, backup_mgr) delay = float(os.getenv("NOSTR_TEST_DELAY", "5")) size = 16 diff --git a/src/tests/test_password_change.py b/src/tests/test_password_change.py index 5be68fb..a3880a8 100644 --- a/src/tests/test_password_change.py +++ b/src/tests/test_password_change.py @@ -10,6 +10,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.entry_management import EntryManager from password_manager.config_manager import ConfigManager +from password_manager.backup import BackupManager from password_manager.vault import Vault from password_manager.manager import PasswordManager, EncryptionMode @@ -18,7 +19,8 @@ def test_change_password_triggers_nostr_backup(monkeypatch): with TemporaryDirectory() as tmpdir: fp = Path(tmpdir) vault, enc_mgr = create_vault(fp, TEST_SEED, TEST_PASSWORD) - entry_mgr = EntryManager(vault, fp) + backup_mgr = BackupManager(fp) + entry_mgr = EntryManager(vault, backup_mgr) cfg_mgr = ConfigManager(vault, fp) pm = PasswordManager.__new__(PasswordManager) diff --git a/src/tests/test_password_unlock_after_change.py b/src/tests/test_password_unlock_after_change.py index 5fabc93..f2d6126 100644 --- a/src/tests/test_password_unlock_after_change.py +++ b/src/tests/test_password_unlock_after_change.py @@ -10,6 +10,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.encryption import EncryptionManager from password_manager.vault import Vault from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager from password_manager.config_manager import ConfigManager from password_manager.manager import PasswordManager, EncryptionMode from utils.key_derivation import derive_index_key, derive_key_from_password @@ -29,7 +30,8 @@ def test_password_change_and_unlock(monkeypatch): enc_mgr = EncryptionManager(index_key, fp) seed_mgr = EncryptionManager(seed_key, fp) vault = Vault(enc_mgr, fp) - entry_mgr = EntryManager(vault, fp) + backup_mgr = BackupManager(fp) + entry_mgr = EntryManager(vault, backup_mgr) cfg_mgr = ConfigManager(vault, fp) vault.save_index({"entries": {}}) diff --git a/src/tests/test_profile_management.py b/src/tests/test_profile_management.py index 6aab635..21707ed 100644 --- a/src/tests/test_profile_management.py +++ b/src/tests/test_profile_management.py @@ -14,6 +14,7 @@ import constants import password_manager.manager as manager_module from password_manager.vault import Vault from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager from password_manager.manager import EncryptionMode @@ -51,7 +52,8 @@ def test_add_and_delete_entry(monkeypatch): assert pm.fingerprint_manager.current_fingerprint == fingerprint vault, enc_mgr = create_vault(fingerprint_dir, TEST_SEED, TEST_PASSWORD) - entry_mgr = EntryManager(vault, fingerprint_dir) + backup_mgr = BackupManager(fingerprint_dir) + entry_mgr = EntryManager(vault, backup_mgr) pm.encryption_manager = enc_mgr pm.vault = vault diff --git a/src/tests/test_totp_entry.py b/src/tests/test_totp_entry.py index d79aa41..27b752c 100644 --- a/src/tests/test_totp_entry.py +++ b/src/tests/test_totp_entry.py @@ -10,6 +10,7 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager from password_manager.vault import Vault from password_manager.totp import TotpManager import pyotp @@ -18,7 +19,8 @@ import pyotp def test_add_totp_and_get_code(): with TemporaryDirectory() as tmpdir: vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) - entry_mgr = EntryManager(vault, Path(tmpdir)) + backup_mgr = BackupManager(Path(tmpdir)) + entry_mgr = EntryManager(vault, backup_mgr) uri = entry_mgr.add_totp("Example", TEST_SEED) assert uri.startswith("otpauth://totp/") @@ -41,7 +43,8 @@ def test_add_totp_and_get_code(): def test_totp_time_remaining(monkeypatch): with TemporaryDirectory() as tmpdir: vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) - entry_mgr = EntryManager(vault, Path(tmpdir)) + backup_mgr = BackupManager(Path(tmpdir)) + entry_mgr = EntryManager(vault, backup_mgr) entry_mgr.add_totp("Example", TEST_SEED) @@ -52,7 +55,8 @@ def test_totp_time_remaining(monkeypatch): def test_add_totp_imported(tmp_path): vault, enc = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) - em = EntryManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path) + em = EntryManager(vault, backup_mgr) secret = "JBSWY3DPEHPK3PXP" em.add_totp("Imported", TEST_SEED, secret=secret) entry = em.retrieve_entry(0) From a5aefd854863e10c0643b065388e982c66749eda Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 11:47:38 -0400 Subject: [PATCH 22/33] Pass config manager to backup --- src/password_manager/backup.py | 6 +++++- src/password_manager/manager.py | 13 ++++++++----- src/tests/test_backup_restore.py | 4 +++- src/tests/test_concurrency_stress.py | 10 +++++++--- src/tests/test_entries_empty.py | 4 +++- src/tests/test_entry_add.py | 10 +++++++--- src/tests/test_entry_management_checksum_path.py | 7 +++++-- src/tests/test_export_totp_codes.py | 4 +++- src/tests/test_manager_add_totp.py | 4 +++- src/tests/test_manager_display_totp_codes.py | 7 +++++-- src/tests/test_manager_retrieve_totp.py | 4 +++- src/tests/test_manager_workflow.py | 4 +++- src/tests/test_nostr_backup.py | 4 +++- src/tests/test_nostr_dummy_client.py | 4 +++- src/tests/test_nostr_index_size.py | 4 +++- src/tests/test_password_change.py | 4 ++-- src/tests/test_password_unlock_after_change.py | 4 ++-- src/tests/test_portable_backup.py | 4 +++- src/tests/test_profile_management.py | 4 +++- src/tests/test_totp_entry.py | 10 +++++++--- 20 files changed, 81 insertions(+), 34 deletions(-) diff --git a/src/password_manager/backup.py b/src/password_manager/backup.py index 5a9e5c8..95dacbd 100644 --- a/src/password_manager/backup.py +++ b/src/password_manager/backup.py @@ -19,6 +19,8 @@ import traceback from pathlib import Path from termcolor import colored +from password_manager.config_manager import ConfigManager + from utils.file_lock import exclusive_lock from constants import APP_DIR @@ -37,14 +39,16 @@ class BackupManager: BACKUP_FILENAME_TEMPLATE = "entries_db_backup_{timestamp}.json.enc" - def __init__(self, fingerprint_dir: Path): + def __init__(self, fingerprint_dir: Path, config_manager: ConfigManager): """ Initializes the BackupManager with the fingerprint directory. Parameters: fingerprint_dir (Path): The directory corresponding to the fingerprint. + config_manager (ConfigManager): Configuration manager for profile settings. """ self.fingerprint_dir = fingerprint_dir + self.config_manager = config_manager self.backup_dir = self.fingerprint_dir / "backups" self.backup_dir.mkdir(parents=True, exist_ok=True) self.index_file = self.fingerprint_dir / "seedpass_entries_db.json.enc" diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 0d6afe2..0750457 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -751,7 +751,14 @@ class PasswordManager: raise ValueError("EncryptionManager is not initialized.") # Reinitialize the managers with the updated EncryptionManager and current fingerprint context - self.backup_manager = BackupManager(fingerprint_dir=self.fingerprint_dir) + self.config_manager = ConfigManager( + vault=self.vault, + fingerprint_dir=self.fingerprint_dir, + ) + self.backup_manager = BackupManager( + fingerprint_dir=self.fingerprint_dir, + config_manager=self.config_manager, + ) self.entry_manager = EntryManager( vault=self.vault, backup_manager=self.backup_manager, @@ -764,10 +771,6 @@ class PasswordManager: ) # Load relay configuration and initialize NostrClient - self.config_manager = ConfigManager( - vault=self.vault, - fingerprint_dir=self.fingerprint_dir, - ) config = self.config_manager.load_config() relay_list = config.get("relays", list(DEFAULT_RELAYS)) self.inactivity_timeout = config.get( diff --git a/src/tests/test_backup_restore.py b/src/tests/test_backup_restore.py index ac61b46..95e0ff3 100644 --- a/src/tests/test_backup_restore.py +++ b/src/tests/test_backup_restore.py @@ -9,13 +9,15 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.backup import BackupManager +from password_manager.config_manager import ConfigManager def test_backup_restore_workflow(monkeypatch): with TemporaryDirectory() as tmpdir: fp_dir = Path(tmpdir) vault, enc_mgr = create_vault(fp_dir, TEST_SEED, TEST_PASSWORD) - backup_mgr = BackupManager(fp_dir) + cfg_mgr = ConfigManager(vault, fp_dir) + backup_mgr = BackupManager(fp_dir, cfg_mgr) index_file = fp_dir / "seedpass_entries_db.json.enc" diff --git a/src/tests/test_concurrency_stress.py b/src/tests/test_concurrency_stress.py index 109e337..da79dd4 100644 --- a/src/tests/test_concurrency_stress.py +++ b/src/tests/test_concurrency_stress.py @@ -9,6 +9,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.encryption import EncryptionManager from password_manager.vault import Vault from password_manager.backup import BackupManager +from password_manager.config_manager import ConfigManager from utils.key_derivation import derive_index_key, derive_key_from_password @@ -34,9 +35,12 @@ def _reader(index_key: bytes, dir_path: Path, loops: int, out: Queue) -> None: out.put(repr(e)) -def _backup(dir_path: Path, loops: int, out: Queue) -> None: +def _backup(index_key: bytes, dir_path: Path, loops: int, out: Queue) -> None: try: - bm = BackupManager(dir_path) + enc = EncryptionManager(index_key, dir_path) + vault = Vault(enc, dir_path) + cfg = ConfigManager(vault, dir_path) + bm = BackupManager(dir_path, cfg) for _ in range(loops): bm.create_backup() except Exception as e: # pragma: no cover - capture @@ -58,7 +62,7 @@ def test_concurrency_stress(tmp_path: Path, loops: int, _): Process(target=_writer, args=(index_key, tmp_path, loops, q)), Process(target=_reader, args=(index_key, tmp_path, loops, q)), Process(target=_reader, args=(index_key, tmp_path, loops, q)), - Process(target=_backup, args=(tmp_path, loops, q)), + Process(target=_backup, args=(index_key, tmp_path, loops, q)), ] for p in procs: diff --git a/src/tests/test_entries_empty.py b/src/tests/test_entries_empty.py index b3ac16e..f9700a5 100644 --- a/src/tests/test_entries_empty.py +++ b/src/tests/test_entries_empty.py @@ -8,12 +8,14 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.entry_management import EntryManager from password_manager.backup import BackupManager from password_manager.vault import Vault +from password_manager.config_manager import ConfigManager def test_list_entries_empty(): with TemporaryDirectory() as tmpdir: vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) - backup_mgr = BackupManager(Path(tmpdir)) + cfg_mgr = ConfigManager(vault, Path(tmpdir)) + backup_mgr = BackupManager(Path(tmpdir), cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) entries = entry_mgr.list_entries() diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py index f5cb1f4..b1f625d 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -11,12 +11,14 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.entry_management import EntryManager from password_manager.backup import BackupManager from password_manager.vault import Vault +from password_manager.config_manager import ConfigManager def test_add_and_retrieve_entry(): with TemporaryDirectory() as tmpdir: vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) - backup_mgr = BackupManager(Path(tmpdir)) + cfg_mgr = ConfigManager(vault, Path(tmpdir)) + backup_mgr = BackupManager(Path(tmpdir), cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) index = entry_mgr.add_entry("example.com", 12, "user") @@ -49,7 +51,8 @@ def test_add_and_retrieve_entry(): def test_round_trip_entry_types(method, expected_type): with TemporaryDirectory() as tmpdir: vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) - backup_mgr = BackupManager(Path(tmpdir)) + cfg_mgr = ConfigManager(vault, Path(tmpdir)) + backup_mgr = BackupManager(Path(tmpdir), cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) if method == "add_entry": @@ -71,7 +74,8 @@ def test_round_trip_entry_types(method, expected_type): def test_legacy_entry_defaults_to_password(): with TemporaryDirectory() as tmpdir: vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) - backup_mgr = BackupManager(Path(tmpdir)) + cfg_mgr = ConfigManager(vault, Path(tmpdir)) + backup_mgr = BackupManager(Path(tmpdir), cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) index = entry_mgr.add_entry("example.com", 8) diff --git a/src/tests/test_entry_management_checksum_path.py b/src/tests/test_entry_management_checksum_path.py index 05e8d97..7f75b65 100644 --- a/src/tests/test_entry_management_checksum_path.py +++ b/src/tests/test_entry_management_checksum_path.py @@ -8,13 +8,15 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.entry_management import EntryManager from password_manager.backup import BackupManager from password_manager.vault import Vault +from password_manager.config_manager import ConfigManager def test_update_checksum_writes_to_expected_path(): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) - backup_mgr = BackupManager(tmp_path) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) # create an empty index file @@ -29,7 +31,8 @@ def test_backup_index_file_creates_backup_in_directory(): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) - backup_mgr = BackupManager(tmp_path) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) vault.save_index({"entries": {}}) diff --git a/src/tests/test_export_totp_codes.py b/src/tests/test_export_totp_codes.py index 5250d28..2f474da 100644 --- a/src/tests/test_export_totp_codes.py +++ b/src/tests/test_export_totp_codes.py @@ -11,6 +11,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.entry_management import EntryManager from password_manager.backup import BackupManager from password_manager.manager import PasswordManager, EncryptionMode +from password_manager.config_manager import ConfigManager from password_manager.totp import TotpManager @@ -21,7 +22,8 @@ class FakeNostrClient: def test_handle_export_totp_codes(monkeypatch, tmp_path): vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) - backup_mgr = BackupManager(tmp_path) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) pm = PasswordManager.__new__(PasswordManager) diff --git a/src/tests/test_manager_add_totp.py b/src/tests/test_manager_add_totp.py index 0a1e102..56bec5a 100644 --- a/src/tests/test_manager_add_totp.py +++ b/src/tests/test_manager_add_totp.py @@ -10,6 +10,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.entry_management import EntryManager from password_manager.backup import BackupManager from password_manager.manager import PasswordManager, EncryptionMode +from password_manager.config_manager import ConfigManager class FakeNostrClient: @@ -25,7 +26,8 @@ def test_handle_add_totp(monkeypatch, capsys): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) - backup_mgr = BackupManager(tmp_path) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) pm = PasswordManager.__new__(PasswordManager) diff --git a/src/tests/test_manager_display_totp_codes.py b/src/tests/test_manager_display_totp_codes.py index 0c52bfe..bff96e8 100644 --- a/src/tests/test_manager_display_totp_codes.py +++ b/src/tests/test_manager_display_totp_codes.py @@ -9,6 +9,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.entry_management import EntryManager from password_manager.backup import BackupManager from password_manager.manager import PasswordManager, EncryptionMode +from password_manager.config_manager import ConfigManager class FakeNostrClient: @@ -24,7 +25,8 @@ def test_handle_display_totp_codes(monkeypatch, capsys): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) - backup_mgr = BackupManager(tmp_path) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) pm = PasswordManager.__new__(PasswordManager) @@ -62,7 +64,8 @@ def test_display_totp_codes_excludes_blacklisted(monkeypatch, capsys): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) - backup_mgr = BackupManager(tmp_path) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) pm = PasswordManager.__new__(PasswordManager) diff --git a/src/tests/test_manager_retrieve_totp.py b/src/tests/test_manager_retrieve_totp.py index 4b28bfc..0ee97e8 100644 --- a/src/tests/test_manager_retrieve_totp.py +++ b/src/tests/test_manager_retrieve_totp.py @@ -9,6 +9,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.entry_management import EntryManager from password_manager.backup import BackupManager from password_manager.manager import PasswordManager, EncryptionMode, TotpManager +from password_manager.config_manager import ConfigManager class FakeNostrClient: @@ -24,7 +25,8 @@ def test_handle_retrieve_totp_entry(monkeypatch, capsys): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) - backup_mgr = BackupManager(tmp_path) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) pm = PasswordManager.__new__(PasswordManager) diff --git a/src/tests/test_manager_workflow.py b/src/tests/test_manager_workflow.py index 8d00a8c..60ccf14 100644 --- a/src/tests/test_manager_workflow.py +++ b/src/tests/test_manager_workflow.py @@ -9,6 +9,7 @@ from password_manager.entry_management import EntryManager from password_manager.vault import Vault from password_manager.backup import BackupManager from password_manager.manager import PasswordManager, EncryptionMode +from password_manager.config_manager import ConfigManager class FakePasswordGenerator: @@ -29,7 +30,8 @@ def test_manager_workflow(monkeypatch): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) - backup_mgr = BackupManager(tmp_path) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) monkeypatch.setattr("password_manager.manager.NostrClient", FakeNostrClient) diff --git a/src/tests/test_nostr_backup.py b/src/tests/test_nostr_backup.py index 0d0ab9d..b4ca998 100644 --- a/src/tests/test_nostr_backup.py +++ b/src/tests/test_nostr_backup.py @@ -10,6 +10,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.entry_management import EntryManager from password_manager.backup import BackupManager from password_manager.vault import Vault +from password_manager.config_manager import ConfigManager from nostr.client import NostrClient @@ -17,7 +18,8 @@ def test_backup_and_publish_to_nostr(): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) - backup_mgr = BackupManager(tmp_path) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) # create an index by adding an entry diff --git a/src/tests/test_nostr_dummy_client.py b/src/tests/test_nostr_dummy_client.py index 0f94836..89bc250 100644 --- a/src/tests/test_nostr_dummy_client.py +++ b/src/tests/test_nostr_dummy_client.py @@ -5,12 +5,14 @@ import math from helpers import create_vault, dummy_nostr_client from password_manager.entry_management import EntryManager from password_manager.backup import BackupManager +from password_manager.config_manager import ConfigManager from nostr.client import prepare_snapshot def test_manifest_generation(tmp_path): vault, enc_mgr = create_vault(tmp_path) - backup_mgr = BackupManager(tmp_path) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) entry_mgr.add_entry("example.com", 12) entry_mgr.add_entry("test.com", 12) diff --git a/src/tests/test_nostr_index_size.py b/src/tests/test_nostr_index_size.py index 7860cad..cb93229 100644 --- a/src/tests/test_nostr_index_size.py +++ b/src/tests/test_nostr_index_size.py @@ -18,6 +18,7 @@ from password_manager.encryption import EncryptionManager from password_manager.entry_management import EntryManager from password_manager.backup import BackupManager from password_manager.vault import Vault +from password_manager.config_manager import ConfigManager from nostr.client import NostrClient, Kind, KindStandard @@ -41,7 +42,8 @@ def test_nostr_index_size_limits(): ) npub = client.key_manager.get_npub() vault = Vault(enc_mgr, tmpdir) - backup_mgr = BackupManager(Path(tmpdir)) + cfg_mgr = ConfigManager(vault, Path(tmpdir)) + backup_mgr = BackupManager(Path(tmpdir), cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) delay = float(os.getenv("NOSTR_TEST_DELAY", "5")) diff --git a/src/tests/test_password_change.py b/src/tests/test_password_change.py index a3880a8..7401559 100644 --- a/src/tests/test_password_change.py +++ b/src/tests/test_password_change.py @@ -19,9 +19,9 @@ def test_change_password_triggers_nostr_backup(monkeypatch): with TemporaryDirectory() as tmpdir: fp = Path(tmpdir) vault, enc_mgr = create_vault(fp, TEST_SEED, TEST_PASSWORD) - backup_mgr = BackupManager(fp) - entry_mgr = EntryManager(vault, backup_mgr) cfg_mgr = ConfigManager(vault, fp) + backup_mgr = BackupManager(fp, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) pm = PasswordManager.__new__(PasswordManager) pm.encryption_mode = EncryptionMode.SEED_ONLY diff --git a/src/tests/test_password_unlock_after_change.py b/src/tests/test_password_unlock_after_change.py index f2d6126..22b8d6c 100644 --- a/src/tests/test_password_unlock_after_change.py +++ b/src/tests/test_password_unlock_after_change.py @@ -30,9 +30,9 @@ def test_password_change_and_unlock(monkeypatch): enc_mgr = EncryptionManager(index_key, fp) seed_mgr = EncryptionManager(seed_key, fp) vault = Vault(enc_mgr, fp) - backup_mgr = BackupManager(fp) - entry_mgr = EntryManager(vault, backup_mgr) cfg_mgr = ConfigManager(vault, fp) + backup_mgr = BackupManager(fp, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) vault.save_index({"entries": {}}) cfg_mgr.save_config( diff --git a/src/tests/test_portable_backup.py b/src/tests/test_portable_backup.py index 674e841..b22feb7 100644 --- a/src/tests/test_portable_backup.py +++ b/src/tests/test_portable_backup.py @@ -11,6 +11,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.encryption import EncryptionManager from password_manager.vault import Vault from password_manager.backup import BackupManager +from password_manager.config_manager import ConfigManager from password_manager.portable_backup import export_backup, import_backup from utils.key_derivation import derive_index_key, derive_key_from_password @@ -27,7 +28,8 @@ def setup_vault(tmp: Path): index_key = derive_index_key(SEED) enc_mgr = EncryptionManager(index_key, tmp) vault = Vault(enc_mgr, tmp) - backup = BackupManager(tmp) + cfg = ConfigManager(vault, tmp) + backup = BackupManager(tmp, cfg) return vault, backup diff --git a/src/tests/test_profile_management.py b/src/tests/test_profile_management.py index 21707ed..ae5dcce 100644 --- a/src/tests/test_profile_management.py +++ b/src/tests/test_profile_management.py @@ -16,6 +16,7 @@ from password_manager.vault import Vault from password_manager.entry_management import EntryManager from password_manager.backup import BackupManager from password_manager.manager import EncryptionMode +from password_manager.config_manager import ConfigManager def test_add_and_delete_entry(monkeypatch): @@ -52,7 +53,8 @@ def test_add_and_delete_entry(monkeypatch): assert pm.fingerprint_manager.current_fingerprint == fingerprint vault, enc_mgr = create_vault(fingerprint_dir, TEST_SEED, TEST_PASSWORD) - backup_mgr = BackupManager(fingerprint_dir) + cfg_mgr = ConfigManager(vault, fingerprint_dir) + backup_mgr = BackupManager(fingerprint_dir, cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) pm.encryption_manager = enc_mgr diff --git a/src/tests/test_totp_entry.py b/src/tests/test_totp_entry.py index 27b752c..4051505 100644 --- a/src/tests/test_totp_entry.py +++ b/src/tests/test_totp_entry.py @@ -12,6 +12,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.entry_management import EntryManager from password_manager.backup import BackupManager from password_manager.vault import Vault +from password_manager.config_manager import ConfigManager from password_manager.totp import TotpManager import pyotp @@ -19,7 +20,8 @@ import pyotp def test_add_totp_and_get_code(): with TemporaryDirectory() as tmpdir: vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) - backup_mgr = BackupManager(Path(tmpdir)) + cfg_mgr = ConfigManager(vault, Path(tmpdir)) + backup_mgr = BackupManager(Path(tmpdir), cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) uri = entry_mgr.add_totp("Example", TEST_SEED) @@ -43,7 +45,8 @@ def test_add_totp_and_get_code(): def test_totp_time_remaining(monkeypatch): with TemporaryDirectory() as tmpdir: vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) - backup_mgr = BackupManager(Path(tmpdir)) + cfg_mgr = ConfigManager(vault, Path(tmpdir)) + backup_mgr = BackupManager(Path(tmpdir), cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) entry_mgr.add_totp("Example", TEST_SEED) @@ -55,7 +58,8 @@ def test_totp_time_remaining(monkeypatch): def test_add_totp_imported(tmp_path): vault, enc = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) - backup_mgr = BackupManager(tmp_path) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) em = EntryManager(vault, backup_mgr) secret = "JBSWY3DPEHPK3PXP" em.add_totp("Imported", TEST_SEED, secret=secret) From aa1edddf9f7ecb464b8d9257c71d6d90db4b38ec Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 11:54:06 -0400 Subject: [PATCH 23/33] Add optional secondary backup location --- src/password_manager/backup.py | 32 +++++++++++++++++++++++++++----- src/tests/test_backup_restore.py | 21 +++++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/password_manager/backup.py b/src/password_manager/backup.py index 95dacbd..e0fd8e0 100644 --- a/src/password_manager/backup.py +++ b/src/password_manager/backup.py @@ -40,12 +40,14 @@ class BackupManager: BACKUP_FILENAME_TEMPLATE = "entries_db_backup_{timestamp}.json.enc" def __init__(self, fingerprint_dir: Path, config_manager: ConfigManager): - """ - Initializes the BackupManager with the fingerprint directory. + """Initialize BackupManager for a specific profile. - Parameters: - fingerprint_dir (Path): The directory corresponding to the fingerprint. - config_manager (ConfigManager): Configuration manager for profile settings. + Parameters + ---------- + fingerprint_dir : Path + Directory for this profile. + config_manager : ConfigManager + Configuration manager used for retrieving settings. """ self.fingerprint_dir = fingerprint_dir self.config_manager = config_manager @@ -76,10 +78,30 @@ class BackupManager: shutil.copy2(index_file, backup_file) logger.info(f"Backup created successfully at '{backup_file}'.") print(colored(f"Backup created successfully at '{backup_file}'.", "green")) + + self._create_additional_backup(backup_file) except Exception as e: logger.error(f"Failed to create backup: {e}", exc_info=True) print(colored(f"Error: Failed to create backup: {e}", "red")) + def _create_additional_backup(self, backup_file: Path) -> None: + """Write a copy of *backup_file* to the configured secondary location.""" + path = self.config_manager.get_additional_backup_path() + if not path: + return + + try: + dest_dir = Path(path).expanduser() + dest_dir.mkdir(parents=True, exist_ok=True) + dest_file = dest_dir / f"{self.fingerprint_dir.name}_{backup_file.name}" + shutil.copy2(backup_file, dest_file) + logger.info(f"Additional backup created at '{dest_file}'.") + except Exception as e: # pragma: no cover - best-effort logging + logger.error( + f"Failed to write additional backup to '{path}': {e}", + exc_info=True, + ) + def restore_latest_backup(self) -> None: try: backup_files = sorted( diff --git a/src/tests/test_backup_restore.py b/src/tests/test_backup_restore.py index 95e0ff3..5c223fb 100644 --- a/src/tests/test_backup_restore.py +++ b/src/tests/test_backup_restore.py @@ -63,3 +63,24 @@ def test_backup_restore_workflow(monkeypatch): current = vault.load_index() backup_mgr.restore_backup_by_timestamp(1111) assert vault.load_index() == current + + +def test_additional_backup_location(monkeypatch): + with TemporaryDirectory() as tmpdir, TemporaryDirectory() as extra: + fp_dir = Path(tmpdir) + vault, enc_mgr = create_vault(fp_dir, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, fp_dir) + cfg_mgr.set_additional_backup_path(extra) + backup_mgr = BackupManager(fp_dir, cfg_mgr) + + vault.save_index({"schema_version": 2, "entries": {"a": {}}}) + + monkeypatch.setattr(time, "time", lambda: 3333) + backup_mgr.create_backup() + + backup = fp_dir / "backups" / "entries_db_backup_3333.json.enc" + assert backup.exists() + + extra_file = Path(extra) / f"{fp_dir.name}_entries_db_backup_3333.json.enc" + assert extra_file.exists() + assert extra_file.stat().st_mode & 0o777 == 0o600 From ffa76799e33e2a87ce5d1a245f61f0fe871cdf81 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 12:03:02 -0400 Subject: [PATCH 24/33] Add configurable secondary backup location --- src/main.py | 61 ++++++++++++++++++++++++++++++--- src/tests/test_settings_menu.py | 12 +++++++ 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/src/main.py b/src/main.py index 14aedc5..9715b78 100644 --- a/src/main.py +++ b/src/main.py @@ -422,6 +422,54 @@ def handle_set_inactivity_timeout(password_manager: PasswordManager) -> None: print(colored(f"Error: {e}", "red")) +def handle_set_additional_backup_location(pm: PasswordManager) -> None: + """Configure an optional second backup directory.""" + cfg_mgr = pm.config_manager + if cfg_mgr is None: + print(colored("Configuration manager unavailable.", "red")) + return + try: + current = cfg_mgr.get_additional_backup_path() + if current: + print(colored(f"Current path: {current}", "cyan")) + else: + print(colored("No additional backup location configured.", "cyan")) + except Exception as e: + logging.error(f"Error loading backup path: {e}") + print(colored(f"Error: {e}", "red")) + return + + value = input( + "Enter directory for extra backups (leave blank to disable): " + ).strip() + if not value: + try: + cfg_mgr.set_additional_backup_path(None) + print(colored("Additional backup location disabled.", "green")) + except Exception as e: + logging.error(f"Error clearing path: {e}") + print(colored(f"Error: {e}", "red")) + return + + try: + path = Path(value).expanduser() + path.mkdir(parents=True, exist_ok=True) + test_file = path / ".seedpass_write_test" + with open(test_file, "w") as f: + f.write("test") + test_file.unlink() + except Exception as e: + print(colored(f"Path not writable: {e}", "red")) + return + + try: + cfg_mgr.set_additional_backup_path(str(path)) + print(colored(f"Additional backups will be copied to {path}", "green")) + except Exception as e: + logging.error(f"Error saving backup path: {e}") + print(colored(f"Error: {e}", "red")) + + def handle_profiles_menu(password_manager: PasswordManager) -> None: """Submenu for managing seed profiles.""" while True: @@ -504,9 +552,10 @@ def handle_settings(password_manager: PasswordManager) -> None: print("6. Export database") print("7. Import database") print("8. Export 2FA codes") - print("9. Set inactivity timeout") - print("10. Lock Vault") - print("11. Back") + print("9. Set additional backup location") + print("10. Set inactivity timeout") + print("11. Lock Vault") + print("12. Back") choice = input("Select an option: ").strip() if choice == "1": handle_profiles_menu(password_manager) @@ -527,12 +576,14 @@ def handle_settings(password_manager: PasswordManager) -> None: elif choice == "8": password_manager.handle_export_totp_codes() elif choice == "9": - handle_set_inactivity_timeout(password_manager) + handle_set_additional_backup_location(password_manager) elif choice == "10": + handle_set_inactivity_timeout(password_manager) + elif choice == "11": password_manager.lock_vault() print(colored("Vault locked. Please re-enter your password.", "yellow")) password_manager.unlock_vault() - elif choice == "11": + elif choice == "12": break else: print(colored("Invalid choice.", "red")) diff --git a/src/tests/test_settings_menu.py b/src/tests/test_settings_menu.py index 4bf2cb1..9c587dc 100644 --- a/src/tests/test_settings_menu.py +++ b/src/tests/test_settings_menu.py @@ -86,3 +86,15 @@ def test_relay_and_profile_actions(monkeypatch, capsys): out = capsys.readouterr().out assert fp1 in out assert fp2 in out + + +def test_settings_menu_additional_backup(monkeypatch): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + pm, cfg_mgr, fp_mgr = setup_pm(tmp_path, monkeypatch) + + inputs = iter(["9", "12"]) + with patch("main.handle_set_additional_backup_location") as handler: + with patch("builtins.input", side_effect=lambda *_: next(inputs)): + main.handle_settings(pm) + handler.assert_called_once_with(pm) From bb6a1416769bc2745bbb82826f421c461a2c5582 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 12:38:19 -0400 Subject: [PATCH 25/33] Add test for additional backup path --- src/tests/test_additional_backup.py | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/tests/test_additional_backup.py diff --git a/src/tests/test_additional_backup.py b/src/tests/test_additional_backup.py new file mode 100644 index 0000000..5597394 --- /dev/null +++ b/src/tests/test_additional_backup.py @@ -0,0 +1,37 @@ +import time +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import create_vault, TEST_SEED, TEST_PASSWORD + +from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager +from password_manager.config_manager import ConfigManager + + +def test_entry_manager_additional_backup(monkeypatch): + with TemporaryDirectory() as tmpdir, TemporaryDirectory() as extra: + fp_dir = Path(tmpdir) + vault, _ = create_vault(fp_dir, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, fp_dir) + cfg_mgr.set_additional_backup_path(extra) + backup_mgr = BackupManager(fp_dir, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + + monkeypatch.setattr(time, "time", lambda: 1111) + entry_mgr.add_entry("example.com", 12) + + backup = fp_dir / "backups" / "entries_db_backup_1111.json.enc" + extra_file = Path(extra) / f"{fp_dir.name}_entries_db_backup_1111.json.enc" + assert backup.exists() + assert extra_file.exists() + + cfg_mgr.set_additional_backup_path(None) + + monkeypatch.setattr(time, "time", lambda: 2222) + entry_mgr.add_entry("example.org", 8) + + backup2 = fp_dir / "backups" / "entries_db_backup_2222.json.enc" + assert backup2.exists() + extra_file2 = Path(extra) / f"{fp_dir.name}_entries_db_backup_2222.json.enc" + assert not extra_file2.exists() From 187c3dc55579d1510c07f5b20fc2cf3e8c60da82 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 12:53:02 -0400 Subject: [PATCH 26/33] docs: highlight new 2FA and backup features --- README.md | 24 ++++++++++++++++++------ landing/index.html | 4 ++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ac46ab2..8b2ea11 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,10 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - **Multiple Seed Profiles:** Manage separate seed profiles and switch between them seamlessly. - **Interactive TUI:** Navigate through menus to add, retrieve, and modify entries as well as configure Nostr settings. - **SeedPass 2FA:** Generate TOTP codes with a real-time countdown progress bar. +- **2FA Secret Issuance & Import:** Derive new TOTP secrets from your seed or import existing `otpauth://` URIs. +- **Export 2FA Codes:** Save all stored TOTP entries to an encrypted JSON file for use with other apps. +- **Optional External Backup Location:** Configure a second directory where backups are automatically copied. +- **Auto‑Lock on Inactivity:** Vault locks after a configurable timeout for additional security. ## Prerequisites @@ -117,6 +121,9 @@ seedpass export --file "~/seedpass_backup.json" # Later you can restore it seedpass import --file "~/seedpass_backup.json" + +# Use the **Settings** menu to configure an extra backup directory +# on an external drive. ``` ### Vault JSON Layout @@ -179,11 +186,11 @@ python src/main.py ### Adding a 2FA Entry 1. From the main menu choose **Add Entry** and select **2FA (TOTP)**. -2. Provide a label for the account (for example, `GitHub`). -3. SeedPass automatically chooses the next available derivation index. -4. Optionally specify the TOTP period and digit count. -5. SeedPass will display an `otpauth://` URI and secret that you can manually - enter into your authenticator app. +2. Pick **Make 2FA** to derive a new secret from your seed or **Import 2FA** to paste an existing `otpauth://` URI or secret. +3. Provide a label for the account (for example, `GitHub`). +4. SeedPass automatically chooses the next available derivation index when deriving. +5. Optionally specify the TOTP period and digit count. +6. SeedPass will display the URI and secret so you can add it to your authenticator app. ### Managing Multiple Seeds @@ -234,7 +241,12 @@ Back in the Settings menu you can: * Select `3` to change your master password. * Choose `4` to verify the script checksum. * Choose `5` to back up the parent seed. -* Choose `6` to lock the vault and require re-entry of your password. +* Select `6` to export the database to an encrypted file. +* Choose `7` to import a database from a backup file. +* Select `8` to export all 2FA codes. +* Choose `9` to set an additional backup location. +* Select `10` to change the inactivity timeout. +* Choose `11` to lock the vault and require re-entry of your password. ## Running Tests diff --git a/landing/index.html b/landing/index.html index cce4cf0..5df7d41 100644 --- a/landing/index.html +++ b/landing/index.html @@ -68,6 +68,10 @@