From 073b8c4d4703397ac3b21a5e4ac6f4ed6488c088 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 16 Jul 2025 15:05:11 -0400 Subject: [PATCH 1/5] Add verbose flag to list_entries and update archived view --- src/password_manager/entry_management.py | 84 +++++++++++++----------- src/password_manager/manager.py | 6 +- src/tests/test_archive_restore.py | 38 +++++++++++ 3 files changed, 89 insertions(+), 39 deletions(-) diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index f2e871c..b779580 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -920,6 +920,7 @@ class EntryManager: filter_kind: str | None = None, *, include_archived: bool = False, + verbose: bool = True, ) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]: """List entries in the index with optional sorting and filtering. @@ -932,7 +933,8 @@ class EntryManager: if not entries_data: logger.info("No entries found.") - print(colored("No entries found.", "yellow")) + if verbose: + print(colored("No entries found.", "yellow")) return [] def sort_key(item: Tuple[str, Dict[str, Any]]): @@ -987,51 +989,59 @@ class EntryManager: ) logger.debug(f"Total entries found: {len(entries)}") - for idx, entry in filtered_items: - etype = entry.get("type", entry.get("kind", 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", + if verbose: + for idx, entry in filtered_items: + etype = entry.get( + "type", entry.get("kind", 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") ) - ) - elif etype == EntryType.PASSWORD.value: - print( - colored( - f" Label: {entry.get('label', entry.get('website', ''))}", - "cyan", + print( + colored( + f" Period: {entry.get('period', 30)}s Digits: {entry.get('digits', 6)}", + "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" Archived: {'Yes' if entry.get('archived', entry.get('blacklisted', False)) else 'No'}", - "cyan", + elif etype == EntryType.PASSWORD.value: + print( + colored( + f" Label: {entry.get('label', entry.get('website', ''))}", + "cyan", + ) ) - ) - else: - print(colored(f" Label: {entry.get('label', '')}", "cyan")) - print( - colored( - f" Derivation Index: {entry.get('index', idx)}", - "cyan", + print( + colored( + f" Username: {entry.get('username') or 'N/A'}", "cyan" + ) ) - ) - print("-" * 40) + print(colored(f" URL: {entry.get('url') or 'N/A'}", "cyan")) + print( + colored( + f" Archived: {'Yes' if entry.get('archived', entry.get('blacklisted', False)) else 'No'}", + "cyan", + ) + ) + else: + print(colored(f" Label: {entry.get('label', '')}", "cyan")) + print( + colored( + f" Derivation Index: {entry.get('index', idx)}", + "cyan", + ) + ) + print("-" * 40) return entries except Exception as e: logger.error(f"Failed to list entries: {e}", exc_info=True) - print(colored(f"Error: Failed to list entries: {e}", "red")) + if verbose: + print(colored(f"Error: Failed to list entries: {e}", "red")) return [] def search_entries( diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 8b8ca8a..517ac4a 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -3240,7 +3240,9 @@ class PasswordManager: def handle_view_archived_entries(self) -> None: """Display archived entries and optionally view or restore them.""" try: - archived = self.entry_manager.list_entries(include_archived=True) + archived = self.entry_manager.list_entries( + include_archived=True, verbose=False + ) archived = [e for e in archived if e[4]] if not archived: self.notify("No archived entries found.", level="WARNING") @@ -3286,7 +3288,7 @@ class PasswordManager: self.last_update = time.time() pause() archived = self.entry_manager.list_entries( - include_archived=True + include_archived=True, verbose=False ) archived = [e for e in archived if e[4]] if not archived: diff --git a/src/tests/test_archive_restore.py b/src/tests/test_archive_restore.py index b182866..8332fd5 100644 --- a/src/tests/test_archive_restore.py +++ b/src/tests/test_archive_restore.py @@ -152,3 +152,41 @@ def test_view_archived_entries_removed_after_restore(monkeypatch, capsys): note = pm.notifications.get_nowait() assert note.level == "WARNING" assert note.message == "No archived entries found." + + +def test_archived_entries_menu_hides_active(monkeypatch, capsys): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + + 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 = SimpleNamespace() + pm.fingerprint_dir = tmp_path + pm.is_dirty = False + pm.notifications = queue.Queue() + + archived_idx = entry_mgr.add_entry("archived.com", 8) + active_idx = entry_mgr.add_entry("active.com", 8) + + # Archive only the first entry + monkeypatch.setattr("builtins.input", lambda *_: str(archived_idx)) + pm.handle_archive_entry() + assert entry_mgr.retrieve_entry(archived_idx)["archived"] is True + assert entry_mgr.retrieve_entry(active_idx)["archived"] is False + + # View archived entries and immediately exit + inputs = iter([""]) + monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) + pm.handle_view_archived_entries() + out = capsys.readouterr().out + assert "archived.com" in out + assert "active.com" not in out From b80abff895ac5b873e86ca69019d6d77592be643 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 16 Jul 2025 15:19:48 -0400 Subject: [PATCH 2/5] Handle decryption failures when syncing index --- README.md | 4 ++- src/password_manager/encryption.py | 51 ++++++++++++++++++--------- src/password_manager/manager.py | 16 ++++----- src/password_manager/vault.py | 8 +++-- src/tests/test_index_import_export.py | 2 +- src/tests/test_profiles.py | 33 +++++++++++++++++ 6 files changed, 85 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index c86c184..0f63233 100644 --- a/README.md +++ b/README.md @@ -473,7 +473,9 @@ subfolder (or adjust `APP_DIR` in `constants.py`) if you want to load it with the main application. The fingerprint is printed after creation and the encrypted index is published to Nostr. Use that same seed phrase to load SeedPass. The app checks Nostr on startup and pulls any newer snapshot so your -vault stays in sync across machines. +vault stays in sync across machines. If no snapshot exists or the download +cannot be decrypted (for example when using a brand-new seed), SeedPass +automatically initializes an empty index instead of exiting. ### Automatically Updating the Script Checksum diff --git a/src/password_manager/encryption.py b/src/password_manager/encryption.py index 38da332..ae21416 100644 --- a/src/password_manager/encryption.py +++ b/src/password_manager/encryption.py @@ -223,15 +223,28 @@ class EncryptionManager: return fh.read() def decrypt_and_save_index_from_nostr( - self, encrypted_data: bytes, relative_path: Optional[Path] = None - ) -> None: - """Decrypts data from Nostr and saves it, automatically using the new format.""" + self, + encrypted_data: bytes, + relative_path: Optional[Path] = None, + *, + strict: bool = True, + ) -> bool: + """Decrypts data from Nostr and saves it. + + Parameters + ---------- + encrypted_data: + The payload downloaded from Nostr. + relative_path: + Destination filename under the profile directory. + strict: + When ``True`` (default) re-raise any decryption error. When ``False`` + return ``False`` if decryption fails. + """ if relative_path is None: relative_path = Path("seedpass_entries_db.json.enc") try: - decrypted_data = self.decrypt_data( - encrypted_data - ) # This now handles both formats + decrypted_data = self.decrypt_data(encrypted_data) if USE_ORJSON: data = json_lib.loads(decrypted_data) else: @@ -240,18 +253,22 @@ class EncryptionManager: self.update_checksum(relative_path) logger.info("Index file from Nostr was processed and saved successfully.") print(colored("Index file updated from Nostr successfully.", "green")) - except Exception as e: - logger.error( - f"Failed to decrypt and save data from Nostr: {e}", - exc_info=True, - ) - print( - colored( - f"Error: Failed to decrypt and save data from Nostr: {e}", - "red", + return True + except Exception as e: # pragma: no cover - error handling + if strict: + logger.error( + f"Failed to decrypt and save data from Nostr: {e}", + exc_info=True, ) - ) - raise + print( + colored( + f"Error: Failed to decrypt and save data from Nostr: {e}", + "red", + ) + ) + raise + logger.warning(f"Failed to decrypt index from Nostr: {e}") + return False def update_checksum(self, relative_path: Optional[Path] = None) -> None: """Updates the checksum file for the specified file.""" diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 517ac4a..cf457a2 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1103,8 +1103,10 @@ class PasswordManager: encrypted = deltas[-1] current = self.vault.get_encrypted_index() if current != encrypted: - self.vault.decrypt_and_save_index_from_nostr(encrypted) - logger.info("Local database synchronized from Nostr.") + if self.vault.decrypt_and_save_index_from_nostr( + encrypted, strict=False + ): + logger.info("Local database synchronized from Nostr.") except Exception as e: logger.warning(f"Unable to sync index from Nostr: {e}") finally: @@ -1195,14 +1197,12 @@ class PasswordManager: deltas = asyncio.run(self.nostr_client.fetch_deltas_since(version)) if deltas: encrypted = deltas[-1] - try: - self.vault.decrypt_and_save_index_from_nostr(encrypted) + success = self.vault.decrypt_and_save_index_from_nostr( + encrypted, strict=False + ) + if success: logger.info("Initialized local database from Nostr.") have_data = True - except Exception as err: - logger.warning( - f"Failed to decrypt Nostr data: {err}; treating as new account." - ) except Exception as e: logger.warning(f"Unable to sync index from Nostr: {e}") diff --git a/src/password_manager/vault.py b/src/password_manager/vault.py index e5fe002..93667c1 100644 --- a/src/password_manager/vault.py +++ b/src/password_manager/vault.py @@ -60,9 +60,13 @@ class Vault: """Return the encrypted index bytes if present.""" return self.encryption_manager.get_encrypted_index() - def decrypt_and_save_index_from_nostr(self, encrypted_data: bytes) -> None: + def decrypt_and_save_index_from_nostr( + self, encrypted_data: bytes, *, strict: bool = True + ) -> bool: """Decrypt Nostr payload and overwrite the local index.""" - self.encryption_manager.decrypt_and_save_index_from_nostr(encrypted_data) + return self.encryption_manager.decrypt_and_save_index_from_nostr( + encrypted_data, strict=strict + ) # ----- Config helpers ----- def load_config(self) -> dict: diff --git a/src/tests/test_index_import_export.py b/src/tests/test_index_import_export.py index ea86dd3..04e3194 100644 --- a/src/tests/test_index_import_export.py +++ b/src/tests/test_index_import_export.py @@ -63,7 +63,7 @@ def test_index_export_import_round_trip(): }, } ) - vault.decrypt_and_save_index_from_nostr(encrypted) + assert vault.decrypt_and_save_index_from_nostr(encrypted) loaded = vault.load_index() assert loaded["entries"] == original["entries"] diff --git a/src/tests/test_profiles.py b/src/tests/test_profiles.py index c6cf5ec..7b70d5c 100644 --- a/src/tests/test_profiles.py +++ b/src/tests/test_profiles.py @@ -6,6 +6,9 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from utils.fingerprint_manager import FingerprintManager from password_manager.manager import PasswordManager, EncryptionMode +from helpers import create_vault, dummy_nostr_client +import gzip +from nostr.backup_models import Manifest, ChunkMeta VALID_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" @@ -51,3 +54,33 @@ def test_add_and_switch_fingerprint(monkeypatch): assert pm.current_fingerprint == fingerprint assert fm.current_fingerprint == fingerprint assert pm.fingerprint_dir == expected_dir + + +def test_sync_index_missing_bad_data(monkeypatch, dummy_nostr_client): + client, _relay = dummy_nostr_client + with TemporaryDirectory() as tmpdir: + dir_path = Path(tmpdir) + vault, _enc = create_vault(dir_path) + + pm = PasswordManager.__new__(PasswordManager) + pm.fingerprint_dir = dir_path + pm.vault = vault + pm.nostr_client = client + pm.sync_vault = lambda *a, **k: None + + manifest = Manifest( + ver=1, + algo="aes-gcm", + chunks=[ChunkMeta(id="c0", size=1, hash="00")], + delta_since=None, + ) + monkeypatch.setattr( + client, + "fetch_latest_snapshot", + lambda: (manifest, [gzip.compress(b"garbage")]), + ) + monkeypatch.setattr(client, "fetch_deltas_since", lambda *_a, **_k: []) + + pm.sync_index_from_nostr_if_missing() + data = pm.vault.load_index() + assert data["entries"] == {} From b88a93df29a3c248c8b5fbdcd096b3901fdc05aa Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 16 Jul 2025 15:57:33 -0400 Subject: [PATCH 3/5] Add CI test script and update workflow --- .github/workflows/python-ci.yml | 11 ++++++++--- scripts/run_ci_tests.sh | 12 ++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) create mode 100755 scripts/run_ci_tests.sh diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index b0a353f..0087961 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -81,10 +81,15 @@ jobs: if: github.ref == 'refs/heads/main' || github.event_name == 'schedule' run: echo "NOSTR_E2E=1" >> $GITHUB_ENV - name: Run tests with coverage + timeout-minutes: 16 shell: bash - run: | - pytest ${STRESS_ARGS} --cov=src --cov-report=xml --cov-report=term-missing \ - --cov-fail-under=20 src/tests + run: scripts/run_ci_tests.sh + - name: Upload pytest log + if: always() + uses: actions/upload-artifact@v4 + with: + name: pytest-log-${{ matrix.os }} + path: pytest.log - name: Upload coverage report uses: actions/upload-artifact@v4 with: diff --git a/scripts/run_ci_tests.sh b/scripts/run_ci_tests.sh new file mode 100755 index 0000000..aeabfe8 --- /dev/null +++ b/scripts/run_ci_tests.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -eo pipefail +timeout 15m pytest -vv ${STRESS_ARGS} \ + --cov=src --cov-report=xml --cov-report=term-missing \ + --cov-fail-under=20 src/tests 2>&1 | tee pytest.log +status=${PIPESTATUS[0]} +if [[ $status -eq 124 ]]; then + echo "::error::Tests exceeded 15-minute limit" + tail -n 20 pytest.log + exit 1 +fi +exit $status From 0741744f9999c24991cb40fa9909bb449b4cb589 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 16 Jul 2025 16:06:21 -0400 Subject: [PATCH 4/5] Handle missing timeout in CI script --- scripts/run_ci_tests.sh | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/scripts/run_ci_tests.sh b/scripts/run_ci_tests.sh index aeabfe8..665e3a5 100755 --- a/scripts/run_ci_tests.sh +++ b/scripts/run_ci_tests.sh @@ -1,9 +1,28 @@ #!/usr/bin/env bash set -eo pipefail -timeout 15m pytest -vv ${STRESS_ARGS} \ + +pytest_cmd=(pytest -vv ${STRESS_ARGS} \ --cov=src --cov-report=xml --cov-report=term-missing \ - --cov-fail-under=20 src/tests 2>&1 | tee pytest.log -status=${PIPESTATUS[0]} + --cov-fail-under=20 src/tests) + +timeout_bin="timeout" +if ! command -v "$timeout_bin" >/dev/null 2>&1; then + if command -v gtimeout >/dev/null 2>&1; then + timeout_bin="gtimeout" + else + timeout_bin="" + fi +fi + +if [[ -n "$timeout_bin" ]]; then + $timeout_bin 15m "${pytest_cmd[@]}" 2>&1 | tee pytest.log + status=${PIPESTATUS[0]} +else + echo "timeout command not found; running tests without timeout" >&2 + "${pytest_cmd[@]}" 2>&1 | tee pytest.log + status=${PIPESTATUS[0]} +fi + if [[ $status -eq 124 ]]; then echo "::error::Tests exceeded 15-minute limit" tail -n 20 pytest.log From 479c0345732180cc82941e3f653a80607737c84a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 16 Jul 2025 17:50:08 -0400 Subject: [PATCH 5/5] fix windows ci hangs --- scripts/run_ci_tests.sh | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/scripts/run_ci_tests.sh b/scripts/run_ci_tests.sh index 665e3a5..661aa47 100755 --- a/scripts/run_ci_tests.sh +++ b/scripts/run_ci_tests.sh @@ -1,9 +1,14 @@ #!/usr/bin/env bash set -eo pipefail -pytest_cmd=(pytest -vv ${STRESS_ARGS} \ - --cov=src --cov-report=xml --cov-report=term-missing \ - --cov-fail-under=20 src/tests) +pytest_args=(-vv) +if [[ -n "${STRESS_ARGS:-}" ]]; then + pytest_args+=(${STRESS_ARGS}) +fi +if [[ "${RUNNER_OS:-}" == "Windows" ]]; then + pytest_args+=(-n 1) +fi +pytest_args+=(--cov=src --cov-report=xml --cov-report=term-missing --cov-fail-under=20 src/tests) timeout_bin="timeout" if ! command -v "$timeout_bin" >/dev/null 2>&1; then @@ -15,11 +20,11 @@ if ! command -v "$timeout_bin" >/dev/null 2>&1; then fi if [[ -n "$timeout_bin" ]]; then - $timeout_bin 15m "${pytest_cmd[@]}" 2>&1 | tee pytest.log + $timeout_bin 15m pytest "${pytest_args[@]}" 2>&1 | tee pytest.log status=${PIPESTATUS[0]} else echo "timeout command not found; running tests without timeout" >&2 - "${pytest_cmd[@]}" 2>&1 | tee pytest.log + pytest "${pytest_args[@]}" 2>&1 | tee pytest.log status=${PIPESTATUS[0]} fi