From 0346fc1740550b026193e62f5240baab1cc39ef8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 09:26:37 -0400 Subject: [PATCH 01/33] docs: add entry type field summary --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index f5fdb94..50be806 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,18 @@ SeedPass supports storing more than just passwords and 2FA secrets. You can also keys. The `npub` is wrapped in the `nostr:` URI scheme so any client can scan it, while the `nsec` QR is shown only after a security warning. +The table below summarizes the extra fields stored for each entry type. Every +entry includes a `label`, while only password entries track a `url`. + +| Entry Type | Extra Fields | +|---------------|---------------------------------------------------------------------------------------------------------------------------------------| +| Password | `username`, `url`, `length`, `blacklisted`, optional `notes`, optional `custom_fields` (may include hidden fields) | +| 2FA (TOTP) | `index` or `secret`, `period`, `digits`, optional `notes` | +| SSH Key | `index`, optional `notes` | +| Seed Phrase | `index`, `words`, optional `notes` | +| PGP Key | `index`, `key_type`, optional `user_id`, optional `notes` | +| Nostr Key Pair| `index`, optional `notes` | + ### Managing Multiple Seeds From eace2d95c5bd1562647f214820810e248232cfa6 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 10:11:13 -0400 Subject: [PATCH 02/33] Add archived flag to entries --- src/password_manager/entry_management.py | 50 +++++++++++++++----- src/password_manager/manager.py | 18 +++---- src/tests/test_entry_add.py | 2 +- src/tests/test_manager_add_totp.py | 1 + src/tests/test_manager_display_totp_codes.py | 4 +- src/tests/test_nostr_entry.py | 1 + src/tests/test_ssh_entry.py | 1 + src/tests/test_totp_entry.py | 2 + 8 files changed, 56 insertions(+), 23 deletions(-) diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index 80acb09..d24c29c 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -72,6 +72,9 @@ class EntryManager: and entry.get("type") == EntryType.PASSWORD.value ): entry.pop("website", None) + if "archived" not in entry and "blacklisted" in entry: + entry["archived"] = entry["blacklisted"] + entry.pop("blacklisted", None) logger.debug("Index loaded successfully.") return data except Exception as e: @@ -117,7 +120,7 @@ class EntryManager: length: int, username: Optional[str] = None, url: Optional[str] = None, - blacklisted: bool = False, + archived: bool = False, notes: str = "", custom_fields: List[Dict[str, Any]] | None = None, ) -> int: @@ -128,7 +131,7 @@ class EntryManager: :param length: The desired length of the password. :param username: (Optional) The username associated with the website. :param url: (Optional) The URL of the website. - :param blacklisted: (Optional) Whether the password is blacklisted. Defaults to False. + :param archived: (Optional) Whether the entry is archived. Defaults to False. :param notes: (Optional) Extra notes to attach to the entry. :return: The assigned index of the new entry. """ @@ -142,7 +145,7 @@ class EntryManager: "length": length, "username": username if username else "", "url": url if url else "", - "blacklisted": blacklisted, + "archived": archived, "type": EntryType.PASSWORD.value, "kind": EntryType.PASSWORD.value, "notes": notes, @@ -184,6 +187,7 @@ class EntryManager: label: str, parent_seed: str, *, + archived: bool = False, secret: str | None = None, index: int | None = None, period: int = 30, @@ -205,6 +209,7 @@ class EntryManager: "index": index, "period": period, "digits": digits, + "archived": archived, } else: entry = { @@ -214,6 +219,7 @@ class EntryManager: "secret": secret, "period": period, "digits": digits, + "archived": archived, } data["entries"][str(entry_id)] = entry @@ -234,6 +240,7 @@ class EntryManager: parent_seed: str, index: int | None = None, notes: str = "", + archived: bool = False, ) -> int: """Add a new SSH key pair entry. @@ -253,6 +260,7 @@ class EntryManager: "index": index, "label": label, "notes": notes, + "archived": archived, } self._save_index(data) self.update_checksum() @@ -281,6 +289,7 @@ class EntryManager: key_type: str = "ed25519", user_id: str = "", notes: str = "", + archived: bool = False, ) -> int: """Add a new PGP key entry.""" @@ -297,6 +306,7 @@ class EntryManager: "key_type": key_type, "user_id": user_id, "notes": notes, + "archived": archived, } self._save_index(data) self.update_checksum() @@ -329,6 +339,7 @@ class EntryManager: label: str, index: int | None = None, notes: str = "", + archived: bool = False, ) -> int: """Add a new Nostr key pair entry.""" @@ -343,6 +354,7 @@ class EntryManager: "index": index, "label": label, "notes": notes, + "archived": archived, } self._save_index(data) self.update_checksum() @@ -381,6 +393,7 @@ class EntryManager: index: int | None = None, words_num: int = 24, notes: str = "", + archived: bool = False, ) -> int: """Add a new derived seed phrase entry.""" @@ -396,6 +409,7 @@ class EntryManager: "label": label, "words": words_num, "notes": notes, + "archived": archived, } self._save_index(data) self.update_checksum() @@ -505,13 +519,14 @@ class EntryManager: index: int, username: Optional[str] = None, url: Optional[str] = None, - blacklisted: Optional[bool] = None, + archived: Optional[bool] = None, notes: Optional[str] = None, *, label: Optional[str] = None, period: Optional[int] = None, digits: Optional[int] = None, custom_fields: List[Dict[str, Any]] | None = None, + **legacy, ) -> None: """ Modifies an existing entry based on the provided index and new values. @@ -519,7 +534,7 @@ class EntryManager: :param index: The index number of the entry to modify. :param username: (Optional) The new username (password entries). :param url: (Optional) The new URL (password entries). - :param blacklisted: (Optional) The new blacklist status. + :param archived: (Optional) The new archived status. :param notes: (Optional) New notes to attach to the entry. :param label: (Optional) The new label for the entry. :param period: (Optional) The new TOTP period in seconds. @@ -564,10 +579,15 @@ class EntryManager: entry["url"] = url logger.debug(f"Updated URL to '{url}' for index {index}.") - if blacklisted is not None: - entry["blacklisted"] = blacklisted + if archived is None and "blacklisted" in legacy: + archived = legacy["blacklisted"] + + if archived is not None: + entry["archived"] = archived + if "blacklisted" in entry: + entry.pop("blacklisted", None) logger.debug( - f"Updated blacklist status to '{blacklisted}' for index {index}." + f"Updated archived status to '{archived}' for index {index}." ) if notes is not None: @@ -598,6 +618,14 @@ class EntryManager: colored(f"Error: Failed to modify entry at index {index}: {e}", "red") ) + def archive_entry(self, index: int) -> None: + """Mark the specified entry as archived.""" + self.modify_entry(index, archived=True) + + def restore_entry(self, index: int) -> None: + """Unarchive the specified entry.""" + self.modify_entry(index, archived=False) + def list_entries( self, sort_by: str = "index", filter_kind: str | None = None ) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]: @@ -644,7 +672,7 @@ class EntryManager: label, entry.get("username", ""), entry.get("url", ""), - entry.get("blacklisted", False), + entry.get("archived", entry.get("blacklisted", False)), ) ) else: @@ -677,7 +705,7 @@ class EntryManager: print(colored(f" URL: {entry.get('url') or 'N/A'}", "cyan")) print( colored( - f" Blacklisted: {'Yes' if entry.get('blacklisted', False) else 'No'}", + f" Blacklisted: {'Yes' if entry.get('archived', entry.get('blacklisted', False)) else 'No'}", "cyan", ) ) @@ -739,7 +767,7 @@ class EntryManager: label, username, url, - entry.get("blacklisted", False), + entry.get("archived", entry.get("blacklisted", False)), ) ) else: diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index cb76a02..efbc439 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -939,7 +939,7 @@ class PasswordManager: length, username, url, - blacklisted=False, + archived=False, notes=notes, custom_fields=custom_fields, ) @@ -1545,7 +1545,7 @@ class PasswordManager: length = entry.get("length") username = entry.get("username") url = entry.get("url") - blacklisted = entry.get("blacklisted") + blacklisted = entry.get("archived", entry.get("blacklisted")) notes = entry.get("notes", "") print( @@ -1660,7 +1660,7 @@ class PasswordManager: label = entry.get("label", "") period = int(entry.get("period", 30)) digits = int(entry.get("digits", 6)) - blacklisted = entry.get("blacklisted", False) + blacklisted = entry.get("archived", entry.get("blacklisted", False)) notes = entry.get("notes", "") print( @@ -1751,7 +1751,7 @@ class PasswordManager: self.entry_manager.modify_entry( index, - blacklisted=new_blacklisted, + archived=new_blacklisted, notes=new_notes, label=new_label, period=new_period, @@ -1762,7 +1762,7 @@ class PasswordManager: website_name = entry.get("label", entry.get("website")) username = entry.get("username") url = entry.get("url") - blacklisted = entry.get("blacklisted") + blacklisted = entry.get("archived", entry.get("blacklisted")) notes = entry.get("notes", "") print( @@ -1847,8 +1847,8 @@ class PasswordManager: index, new_username, new_url, - new_blacklisted, - new_notes, + archived=new_blacklisted, + notes=new_notes, label=new_label, custom_fields=custom_fields, ) @@ -1992,7 +1992,7 @@ class PasswordManager: website = entry.get("label", entry.get("website", "")) username = entry.get("username", "") url = entry.get("url", "") - blacklisted = entry.get("blacklisted", False) + blacklisted = entry.get("archived", entry.get("blacklisted", False)) print(color_text(f" Label: {website}", "index")) print(color_text(f" Username: {username or 'N/A'}", "index")) print(color_text(f" URL: {url or 'N/A'}", "index")) @@ -2115,7 +2115,7 @@ class PasswordManager: totp_list: list[tuple[str, int, int, bool]] = [] for idx_str, entry in entries.items(): if entry.get("type") == EntryType.TOTP.value and not entry.get( - "blacklisted", False + "archived", entry.get("blacklisted", False) ): label = entry.get("label", "") period = int(entry.get("period", 30)) diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py index f44f318..5388ff3 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -34,7 +34,7 @@ def test_add_and_retrieve_entry(): "length": 12, "username": "user", "url": "", - "blacklisted": False, + "archived": False, "type": "password", "kind": "password", "notes": "", diff --git a/src/tests/test_manager_add_totp.py b/src/tests/test_manager_add_totp.py index 4b159ad..8ad618b 100644 --- a/src/tests/test_manager_add_totp.py +++ b/src/tests/test_manager_add_totp.py @@ -63,5 +63,6 @@ def test_handle_add_totp(monkeypatch, capsys): "index": 0, "period": 30, "digits": 6, + "archived": False, } 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 b2773e0..649bcd2 100644 --- a/src/tests/test_manager_display_totp_codes.py +++ b/src/tests/test_manager_display_totp_codes.py @@ -61,7 +61,7 @@ def test_handle_display_totp_codes(monkeypatch, capsys): assert "123456" in out -def test_display_totp_codes_excludes_blacklisted(monkeypatch, capsys): +def test_display_totp_codes_excludes_archived(monkeypatch, capsys): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) @@ -83,7 +83,7 @@ def test_display_totp_codes_excludes_blacklisted(monkeypatch, capsys): entry_mgr.add_totp("Visible", TEST_SEED) entry_mgr.add_totp("Hidden", TEST_SEED) - entry_mgr.modify_entry(1, blacklisted=True) + entry_mgr.modify_entry(1, archived=True) monkeypatch.setattr(pm.entry_manager, "get_totp_code", lambda *a, **k: "123456") monkeypatch.setattr( diff --git a/src/tests/test_nostr_entry.py b/src/tests/test_nostr_entry.py index cfedc8f..569dfeb 100644 --- a/src/tests/test_nostr_entry.py +++ b/src/tests/test_nostr_entry.py @@ -30,6 +30,7 @@ def test_nostr_key_determinism(): "index": idx, "label": "main", "notes": "", + "archived": False, } npub1, nsec1 = entry_mgr.get_nostr_key_pair(idx, TEST_SEED) diff --git a/src/tests/test_ssh_entry.py b/src/tests/test_ssh_entry.py index 7a2f552..695034c 100644 --- a/src/tests/test_ssh_entry.py +++ b/src/tests/test_ssh_entry.py @@ -28,6 +28,7 @@ def test_add_and_retrieve_ssh_key_pair(): "index": index, "label": "ssh", "notes": "", + "archived": False, } priv1, pub1 = entry_mgr.get_ssh_key_pair(index, TEST_SEED) diff --git a/src/tests/test_totp_entry.py b/src/tests/test_totp_entry.py index 2b6b301..87f24da 100644 --- a/src/tests/test_totp_entry.py +++ b/src/tests/test_totp_entry.py @@ -35,6 +35,7 @@ def test_add_totp_and_get_code(): "index": 0, "period": 30, "digits": 6, + "archived": False, } code = entry_mgr.get_totp_code(0, TEST_SEED, timestamp=0) @@ -72,6 +73,7 @@ def test_add_totp_imported(tmp_path): "secret": secret, "period": 30, "digits": 6, + "archived": False, } code = em.get_totp_code(0, timestamp=0) assert code == pyotp.TOTP(secret).at(0) From 653ae7066e6b110103a0c4ed03c4e180d60d6339 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 10:30:57 -0400 Subject: [PATCH 03/33] Clarify seed phrase storage --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 50be806..d8d9f8a 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,7 @@ When **Secret Mode** is enabled, SeedPass copies retrieved passwords directly to SeedPass supports storing more than just passwords and 2FA secrets. You can also create entries for: - **SSH Key** – deterministically derive an Ed25519 key pair for servers or git hosting platforms. -- **Seed Phrase** – generate a BIP-39 mnemonic and keep it encrypted until needed. +- **Seed Phrase** – store only the BIP-85 index and word count. The mnemonic is regenerated on demand. - **PGP Key** – derive an OpenPGP key pair from your master seed. - **Nostr Key Pair** – store the index used to derive an `npub`/`nsec` pair for Nostr clients. When you retrieve one of these entries, SeedPass can display QR codes for the @@ -285,7 +285,7 @@ entry includes a `label`, while only password entries track a `url`. | Password | `username`, `url`, `length`, `blacklisted`, optional `notes`, optional `custom_fields` (may include hidden fields) | | 2FA (TOTP) | `index` or `secret`, `period`, `digits`, optional `notes` | | SSH Key | `index`, optional `notes` | -| Seed Phrase | `index`, `words`, optional `notes` | +| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, optional `notes` | | PGP Key | `index`, `key_type`, optional `user_id`, optional `notes` | | Nostr Key Pair| `index`, optional `notes` | From e24526a7efaa8b5d06a0dd4a830237a2a9499c7f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 10:46:02 -0400 Subject: [PATCH 04/33] migrate seed entry field --- src/password_manager/entry_management.py | 7 +++++-- src/password_manager/manager.py | 2 +- src/tests/test_seed_entry.py | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index d24c29c..0268ac3 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -75,6 +75,9 @@ class EntryManager: if "archived" not in entry and "blacklisted" in entry: entry["archived"] = entry["blacklisted"] entry.pop("blacklisted", None) + if "word_count" not in entry and "words" in entry: + entry["word_count"] = entry["words"] + entry.pop("words", None) logger.debug("Index loaded successfully.") return data except Exception as e: @@ -407,7 +410,7 @@ class EntryManager: "kind": EntryType.SEED.value, "index": index, "label": label, - "words": words_num, + "word_count": words_num, "notes": notes, "archived": archived, } @@ -434,7 +437,7 @@ class EntryManager: seed_bytes = Bip39SeedGenerator(parent_seed).Generate() bip85 = BIP85(seed_bytes) - words = int(entry.get("words", 24)) + words = int(entry.get("word_count", entry.get("words", 24))) seed_index = int(entry.get("index", index)) return derive_seed_phrase(bip85, seed_index, words) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index efbc439..91939be 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1458,7 +1458,7 @@ class PasswordManager: from local_bip85.bip85 import BIP85 from bip_utils import Bip39SeedGenerator - words = int(entry.get("words", 24)) + words = int(entry.get("word_count", entry.get("words", 24))) bytes_len = {12: 16, 18: 24, 24: 32}.get(words, 32) seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate() bip85 = BIP85(seed_bytes) diff --git a/src/tests/test_seed_entry.py b/src/tests/test_seed_entry.py index 5b43eb4..a2474e9 100644 --- a/src/tests/test_seed_entry.py +++ b/src/tests/test_seed_entry.py @@ -45,5 +45,5 @@ def test_seed_phrase_determinism(): assert len(phrase24_a.split()) == 24 assert Mnemonic("english").check(phrase12_a) assert Mnemonic("english").check(phrase24_a) - assert entry12.get("words") == 12 - assert entry24.get("words") == 24 + assert entry12.get("word_count") == 12 + assert entry24.get("word_count") == 24 From dbff30619a292fd090e3b3b8b89ad8264ed3a158 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 11:10:07 -0400 Subject: [PATCH 05/33] test: ensure seed entry retrieval omits phrases --- src/tests/test_seed_entry.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/tests/test_seed_entry.py b/src/tests/test_seed_entry.py index a2474e9..772fa55 100644 --- a/src/tests/test_seed_entry.py +++ b/src/tests/test_seed_entry.py @@ -34,6 +34,29 @@ def test_seed_phrase_determinism(): entry12 = entry_mgr.retrieve_entry(idx_12) entry24 = entry_mgr.retrieve_entry(idx_24) + assert entry12 == { + "type": "seed", + "kind": "seed", + "index": idx_12, + "label": "seed12", + "word_count": 12, + "notes": "", + "archived": False, + } + + assert entry24 == { + "type": "seed", + "kind": "seed", + "index": idx_24, + "label": "seed24", + "word_count": 24, + "notes": "", + "archived": False, + } + + assert phrase12_a not in entry12.values() + assert phrase24_a not in entry24.values() + seed_bytes = Bip39SeedGenerator(TEST_SEED).Generate() bip85 = BIP85(seed_bytes) expected12 = derive_seed_phrase(bip85, idx_12, 12) From 8a7af94719544820bdaeb4e1aebf54ff18b78396 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 11:24:14 -0400 Subject: [PATCH 06/33] Add entry archiving features and update menus --- src/main.py | 14 +++-- src/password_manager/entry_management.py | 43 ++++++++++--- src/password_manager/manager.py | 78 +++++++++++++++++++++--- src/tests/test_cli_invalid_input.py | 2 +- 4 files changed, 113 insertions(+), 24 deletions(-) diff --git a/src/main.py b/src/main.py index 35f1298..47ab60f 100644 --- a/src/main.py +++ b/src/main.py @@ -276,9 +276,7 @@ def print_matches( print(color_text(f" Username: {username}", "index")) if url: print(color_text(f" URL: {url}", "index")) - print( - color_text(f" Blacklisted: {'Yes' if blacklisted else 'No'}", "index") - ) + print(color_text(f" Archived: {'Yes' if blacklisted else 'No'}", "index")) print("-" * 40) @@ -752,6 +750,8 @@ def display_menu( 5. Modify an Existing Entry 6. 2FA Codes 7. Settings + 8. Archive Entry + 9. View Archived Entries """ display_fn = getattr(password_manager, "display_stats", None) if callable(display_fn): @@ -781,7 +781,7 @@ def display_menu( print(color_text(menu, "menu")) try: choice = timed_input( - "Enter your choice (1-7) or press Enter to exit: ", + "Enter your choice (1-9) or press Enter to exit: ", inactivity_timeout, ).strip() except TimeoutError: @@ -856,6 +856,12 @@ def display_menu( elif choice == "7": password_manager.update_activity() handle_settings(password_manager) + elif choice == "8": + password_manager.update_activity() + password_manager.handle_archive_entry() + elif choice == "9": + password_manager.update_activity() + password_manager.handle_view_archived_entries() else: print(colored("Invalid choice. Please select a valid option.", "red")) diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index 0268ac3..9d4a8ea 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -630,9 +630,17 @@ class EntryManager: self.modify_entry(index, archived=False) def list_entries( - self, sort_by: str = "index", filter_kind: str | None = None + self, + sort_by: str = "index", + filter_kind: str | None = None, + *, + include_archived: bool = False, ) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]: - """List entries in the index with optional sorting and filtering.""" + """List entries in the index with optional sorting and filtering. + + By default archived entries are omitted unless ``include_archived`` is + ``True``. + """ try: data = self.vault.load_index() entries_data = data.get("entries", {}) @@ -662,6 +670,10 @@ class EntryManager: != filter_kind ): continue + if not include_archived and entry.get( + "archived", entry.get("blacklisted", False) + ): + continue filtered_items.append((int(idx_str), entry)) entries: List[Tuple[int, str, Optional[str], Optional[str], bool]] = [] @@ -708,7 +720,7 @@ class EntryManager: print(colored(f" URL: {entry.get('url') or 'N/A'}", "cyan")) print( colored( - f" Blacklisted: {'Yes' if entry.get('archived', entry.get('blacklisted', False)) else 'No'}", + f" Archived: {'Yes' if entry.get('archived', entry.get('blacklisted', False)) else 'No'}", "cyan", ) ) @@ -880,11 +892,19 @@ class EntryManager: ) def list_all_entries( - self, sort_by: str = "index", filter_kind: str | None = None + self, + sort_by: str = "index", + filter_kind: str | None = None, + *, + include_archived: bool = False, ) -> None: """Display all entries using :meth:`list_entries`.""" try: - entries = self.list_entries(sort_by=sort_by, filter_kind=filter_kind) + entries = self.list_entries( + sort_by=sort_by, + filter_kind=filter_kind, + include_archived=include_archived, + ) if not entries: print(colored("No entries to display.", "yellow")) return @@ -896,9 +916,7 @@ class EntryManager: print(colored(f" Label: {website}", "cyan")) print(colored(f" Username: {username or 'N/A'}", "cyan")) print(colored(f" URL: {url or 'N/A'}", "cyan")) - print( - colored(f" Blacklisted: {'Yes' if blacklisted else 'No'}", "cyan") - ) + print(colored(f" Archived: {'Yes' if blacklisted else 'No'}", "cyan")) print("-" * 40) except Exception as e: @@ -907,7 +925,10 @@ class EntryManager: return def get_entry_summaries( - self, filter_kind: str | None = None + self, + filter_kind: str | None = None, + *, + include_archived: bool = False, ) -> list[tuple[int, str, str]]: """Return a list of entry index, type, and display labels.""" try: @@ -919,6 +940,10 @@ class EntryManager: etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) if filter_kind and etype != filter_kind: continue + if not include_archived and entry.get( + "archived", entry.get("blacklisted", False) + ): + continue if etype == EntryType.PASSWORD.value: label = entry.get("label", entry.get("website", "")) else: diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 91939be..9d26481 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1561,7 +1561,7 @@ class PasswordManager: if blacklisted: print( colored( - f"Warning: This password is blacklisted and should not be used.", + f"Warning: This password is archived and should not be used.", "yellow", ) ) @@ -1589,7 +1589,7 @@ class PasswordManager: print(colored(f"Associated URL: {url or 'N/A'}", "cyan")) print( colored( - f"Blacklist Status: {'Blacklisted' if blacklisted else 'Not Blacklisted'}", + f"Archived Status: {'Archived' if blacklisted else 'Active'}", "cyan", ) ) @@ -1673,7 +1673,7 @@ class PasswordManager: print(colored(f"Current Digits: {digits}", "cyan")) print( colored( - f"Current Blacklist Status: {'Blacklisted' if blacklisted else 'Not Blacklisted'}", + f"Current Archived Status: {'Archived' if blacklisted else 'Active'}", "cyan", ) ) @@ -1708,7 +1708,7 @@ class PasswordManager: ) blacklist_input = ( input( - f'Is this 2FA code blacklisted? (Y/N, current: {"Y" if blacklisted else "N"}): ' + f'Archive this 2FA code? (Y/N, current: {"Y" if blacklisted else "N"}): ' ) .strip() .lower() @@ -1722,7 +1722,7 @@ class PasswordManager: else: print( colored( - "Invalid input for blacklist status. Keeping the current status.", + "Invalid input for archived status. Keeping the current status.", "yellow", ) ) @@ -1776,7 +1776,7 @@ class PasswordManager: print(colored(f"Current URL: {url or 'N/A'}", "cyan")) print( colored( - f"Current Blacklist Status: {'Blacklisted' if blacklisted else 'Not Blacklisted'}", + f"Current Archived Status: {'Archived' if blacklisted else 'Active'}", "cyan", ) ) @@ -1802,7 +1802,7 @@ class PasswordManager: ) blacklist_input = ( input( - f'Is this password blacklisted? (Y/N, current: {"Y" if blacklisted else "N"}): ' + f'Archive this password? (Y/N, current: {"Y" if blacklisted else "N"}): ' ) .strip() .lower() @@ -1816,7 +1816,7 @@ class PasswordManager: else: print( colored( - "Invalid input for blacklist status. Keeping the current status.", + "Invalid input for archived status. Keeping the current status.", "yellow", ) ) @@ -1997,7 +1997,10 @@ class PasswordManager: print(color_text(f" Username: {username or 'N/A'}", "index")) print(color_text(f" URL: {url or 'N/A'}", "index")) print( - color_text(f" Blacklisted: {'Yes' if blacklisted else 'No'}", "index") + color_text( + f" Archived: {'Yes' if blacklisted else 'No'}", + "index", + ) ) print("-" * 40) @@ -2038,7 +2041,9 @@ class PasswordManager: print(colored("Invalid choice.", "red")) continue - summaries = self.entry_manager.get_entry_summaries(filter_kind) + summaries = self.entry_manager.get_entry_summaries( + filter_kind, include_archived=False + ) if not summaries: continue while True: @@ -2103,6 +2108,59 @@ 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_archive_entry(self) -> None: + """Archive an entry without deleting it.""" + try: + index_input = input( + "Enter the index number of the entry to archive: " + ).strip() + if not index_input.isdigit(): + print(colored("Error: Index must be a number.", "red")) + return + index = int(index_input) + self.entry_manager.archive_entry(index) + self.is_dirty = True + self.last_update = time.time() + except Exception as e: + logging.error(f"Error archiving entry: {e}", exc_info=True) + print(colored(f"Error: Failed to archive entry: {e}", "red")) + + def handle_view_archived_entries(self) -> None: + """Display archived entries and optionally restore one.""" + try: + archived = self.entry_manager.list_entries(include_archived=True) + archived = [e for e in archived if e[4]] + if not archived: + print(colored("No archived entries found.", "yellow")) + return + while True: + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > Archived Entries", + ) + print(colored("\n[+] Archived Entries:\n", "green")) + for idx, label, username, url, _ in archived: + print(colored(f"{idx}. {label}", "cyan")) + idx_input = input( + "Enter index to restore or press Enter to go back: " + ).strip() + if not idx_input: + break + if not idx_input.isdigit(): + print(colored("Invalid index.", "red")) + continue + restore_index = int(idx_input) + self.entry_manager.restore_entry(restore_index) + self.is_dirty = True + self.last_update = time.time() + archived = [e for e in archived if e[0] != restore_index] + if not archived: + print(colored("All entries restored.", "green")) + break + except Exception as e: + logging.error(f"Error viewing archived entries: {e}", exc_info=True) + print(colored(f"Error: Failed to view archived entries: {e}", "red")) + def handle_display_totp_codes(self) -> None: """Display all stored TOTP codes with a countdown progress bar.""" try: diff --git a/src/tests/test_cli_invalid_input.py b/src/tests/test_cli_invalid_input.py index 6331c8c..cfdf2bb 100644 --- a/src/tests/test_cli_invalid_input.py +++ b/src/tests/test_cli_invalid_input.py @@ -64,7 +64,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", ""]) + inputs = iter(["10", ""]) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) with pytest.raises(SystemExit): main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) From 466fb2036f53aed31e21be84ce12db8bf9d03c52 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 11:31:45 -0400 Subject: [PATCH 07/33] docs: rename blacklist to archived --- README.md | 15 ++++++++------- src/tests/test_manager_workflow.py | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index d8d9f8a..1ad7ade 100644 --- a/README.md +++ b/README.md @@ -254,9 +254,10 @@ When choosing **Add Entry**, you can now select **Password**, **2FA (TOTP)**, ### Modifying a 2FA Entry 1. From the main menu choose **Modify an Existing Entry** and enter the index of the 2FA code you want to edit. -2. SeedPass will show the current label, period, digit count, and blacklist status. +2. SeedPass will show the current label, period, digit count, and archived status. 3. Enter new values or press **Enter** to keep the existing settings. 4. The updated entry is saved back to your encrypted vault. +5. Archived entries are hidden from lists but can be restored from the **Archived Entries** menu. ### Using Secret Mode @@ -282,12 +283,12 @@ entry includes a `label`, while only password entries track a `url`. | Entry Type | Extra Fields | |---------------|---------------------------------------------------------------------------------------------------------------------------------------| -| Password | `username`, `url`, `length`, `blacklisted`, optional `notes`, optional `custom_fields` (may include hidden fields) | -| 2FA (TOTP) | `index` or `secret`, `period`, `digits`, optional `notes` | -| SSH Key | `index`, optional `notes` | -| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, optional `notes` | -| PGP Key | `index`, `key_type`, optional `user_id`, optional `notes` | -| Nostr Key Pair| `index`, optional `notes` | +| Password | `username`, `url`, `length`, `archived`, optional `notes`, optional `custom_fields` (may include hidden fields) | +| 2FA (TOTP) | `index` or `secret`, `period`, `digits`, `archived`, optional `notes` | +| SSH Key | `index`, `archived`, optional `notes` | +| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes` | +| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes` | +| Nostr Key Pair| `index`, `archived`, optional `notes` | ### Managing Multiple Seeds diff --git a/src/tests/test_manager_workflow.py b/src/tests/test_manager_workflow.py index dac3a2e..ec72c70 100644 --- a/src/tests/test_manager_workflow.py +++ b/src/tests/test_manager_workflow.py @@ -60,7 +60,7 @@ def test_manager_workflow(monkeypatch): "", # new label "user", # new username "", # new url - "", # blacklist status + "", # archive status "", # new notes "n", # edit custom fields ] From 8ce8b812a692c260889cb5bd6f332f26517c8078 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 11:48:11 -0400 Subject: [PATCH 08/33] Add archive entry tests --- src/tests/test_archive_restore.py | 80 +++++++++++++++++++++++++++++++ src/tests/test_entry_add.py | 28 +++++++++++ 2 files changed, 108 insertions(+) create mode 100644 src/tests/test_archive_restore.py diff --git a/src/tests/test_archive_restore.py b/src/tests/test_archive_restore.py new file mode 100644 index 0000000..fe963db --- /dev/null +++ b/src/tests/test_archive_restore.py @@ -0,0 +1,80 @@ +import sys +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace + +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.backup import BackupManager +from password_manager.config_manager import ConfigManager +from password_manager.manager import PasswordManager, EncryptionMode + + +def setup_entry_mgr(tmp_path: Path) -> EntryManager: + vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + return EntryManager(vault, backup_mgr) + + +def test_archive_restore_affects_listing_and_search(): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + em = setup_entry_mgr(tmp_path) + idx = em.add_entry("example.com", 8, "alice") + + assert em.list_entries() == [(idx, "example.com", "alice", "", False)] + assert em.search_entries("example") == [ + (idx, "example.com", "alice", "", False) + ] + + em.archive_entry(idx) + assert em.retrieve_entry(idx)["archived"] is True + assert em.list_entries() == [] + assert em.list_entries(include_archived=True) == [ + (idx, "example.com", "alice", "", True) + ] + assert em.search_entries("example") == [(idx, "example.com", "alice", "", True)] + + em.restore_entry(idx) + assert em.retrieve_entry(idx)["archived"] is False + assert em.list_entries() == [(idx, "example.com", "alice", "", False)] + assert em.search_entries("example") == [ + (idx, "example.com", "alice", "", False) + ] + + +def test_view_archived_entries_cli(monkeypatch): + 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 + + idx = entry_mgr.add_entry("example.com", 8) + + monkeypatch.setattr("builtins.input", lambda *_: str(idx)) + pm.handle_archive_entry() + assert entry_mgr.retrieve_entry(idx)["archived"] is True + + inputs = iter([str(idx), ""]) + monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) + pm.handle_view_archived_entries() + assert entry_mgr.retrieve_entry(idx)["archived"] is False diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py index 5388ff3..7bfdb8d 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -98,3 +98,31 @@ def test_legacy_entry_defaults_to_password(): loaded = entry_mgr._load_index() assert loaded["entries"][str(index)]["type"] == "password" + + +@pytest.mark.parametrize( + "method,args", + [ + ("add_entry", ("site.com", 8)), + ("add_totp", ("totp", TEST_SEED)), + ("add_ssh_key", ("ssh", TEST_SEED)), + ("add_pgp_key", ("pgp", TEST_SEED)), + ("add_nostr_key", ("nostr",)), + ("add_seed", ("seed", TEST_SEED)), + ], +) +def test_add_default_archived_false(method, args): + with TemporaryDirectory() as tmpdir: + vault, _ = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, Path(tmpdir)) + backup_mgr = BackupManager(Path(tmpdir), cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + + if method == "add_totp": + getattr(entry_mgr, method)(*args) + index = 0 + else: + index = getattr(entry_mgr, method)(*args) + + entry = entry_mgr.retrieve_entry(index) + assert entry["archived"] is False From ae7dca3ab84d4d9d4951f7d1b52e4903b50bbd6a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 12:40:36 -0400 Subject: [PATCH 09/33] Prompt to archive entry after retrieval --- src/password_manager/manager.py | 30 +++++++++++++++++++++++++ src/tests/test_custom_fields_display.py | 2 +- src/tests/test_manager_retrieve_totp.py | 3 ++- src/tests/test_manager_workflow.py | 2 ++ src/tests/test_nostr_qr.py | 3 ++- src/tests/test_secret_mode.py | 6 +++-- 6 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 9d26481..541a94d 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1387,6 +1387,11 @@ class PasswordManager: 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")) + choice = input("Archive this entry? (y/N): ").strip().lower() + if choice == "y": + self.entry_manager.archive_entry(index) + self.is_dirty = True + self.last_update = time.time() pause() return if entry_type == EntryType.SSH.value: @@ -1422,6 +1427,11 @@ class PasswordManager: except Exception as e: logging.error(f"Error deriving SSH key pair: {e}", exc_info=True) print(colored(f"Error: Failed to derive SSH keys: {e}", "red")) + choice = input("Archive this entry? (y/N): ").strip().lower() + if choice == "y": + self.entry_manager.archive_entry(index) + self.is_dirty = True + self.last_update = time.time() pause() return if entry_type == EntryType.SEED.value: @@ -1472,6 +1482,11 @@ class PasswordManager: except Exception as e: logging.error(f"Error deriving seed phrase: {e}", exc_info=True) print(colored(f"Error: Failed to derive seed phrase: {e}", "red")) + choice = input("Archive this entry? (y/N): ").strip().lower() + if choice == "y": + self.entry_manager.archive_entry(index) + self.is_dirty = True + self.last_update = time.time() pause() return if entry_type == EntryType.PGP.value: @@ -1505,6 +1520,11 @@ class PasswordManager: except Exception as e: logging.error(f"Error deriving PGP key: {e}", exc_info=True) print(colored(f"Error: Failed to derive PGP key: {e}", "red")) + choice = input("Archive this entry? (y/N): ").strip().lower() + if choice == "y": + self.entry_manager.archive_entry(index) + self.is_dirty = True + self.last_update = time.time() pause() return if entry_type == EntryType.NOSTR.value: @@ -1538,6 +1558,11 @@ class PasswordManager: except Exception as e: logging.error(f"Error deriving Nostr keys: {e}", exc_info=True) print(colored(f"Error: Failed to derive Nostr keys: {e}", "red")) + choice = input("Archive this entry? (y/N): ").strip().lower() + if choice == "y": + self.entry_manager.archive_entry(index) + self.is_dirty = True + self.last_update = time.time() pause() return @@ -1625,6 +1650,11 @@ class PasswordManager: print(colored(f" {label}: {value}", "cyan")) else: print(colored("Error: Failed to retrieve the password.", "red")) + choice = input("Archive this entry? (y/N): ").strip().lower() + if choice == "y": + self.entry_manager.archive_entry(index) + self.is_dirty = True + self.last_update = time.time() pause() except Exception as e: logging.error(f"Error during password retrieval: {e}", exc_info=True) diff --git a/src/tests/test_custom_fields_display.py b/src/tests/test_custom_fields_display.py index f8966fc..50745ae 100644 --- a/src/tests/test_custom_fields_display.py +++ b/src/tests/test_custom_fields_display.py @@ -42,7 +42,7 @@ def test_retrieve_entry_shows_custom_fields(monkeypatch, capsys): ], ) - inputs = iter(["0", "y"]) + inputs = iter(["0", "y", "n"]) monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) pm.handle_retrieve_entry() diff --git a/src/tests/test_manager_retrieve_totp.py b/src/tests/test_manager_retrieve_totp.py index b67fe27..ae0f8d2 100644 --- a/src/tests/test_manager_retrieve_totp.py +++ b/src/tests/test_manager_retrieve_totp.py @@ -43,7 +43,8 @@ def test_handle_retrieve_totp_entry(monkeypatch, capsys): entry_mgr.add_totp("Example", TEST_SEED) - monkeypatch.setattr("builtins.input", lambda *a, **k: "0") + inputs = iter(["0", "n"]) + monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) monkeypatch.setattr(pm.entry_manager, "get_totp_code", lambda *a, **k: "123456") monkeypatch.setattr( pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1 diff --git a/src/tests/test_manager_workflow.py b/src/tests/test_manager_workflow.py index ec72c70..5a95cd1 100644 --- a/src/tests/test_manager_workflow.py +++ b/src/tests/test_manager_workflow.py @@ -46,6 +46,7 @@ def test_manager_workflow(monkeypatch): pm.nostr_client = FakeNostrClient() pm.fingerprint_dir = tmp_path pm.is_dirty = False + pm.secret_mode_enabled = False inputs = iter( [ @@ -56,6 +57,7 @@ def test_manager_workflow(monkeypatch): "n", # add custom field "", # length (default) "0", # retrieve index + "n", # archive entry prompt "0", # modify index "", # new label "user", # new username diff --git a/src/tests/test_nostr_qr.py b/src/tests/test_nostr_qr.py index fe8cc84..f967c5c 100644 --- a/src/tests/test_nostr_qr.py +++ b/src/tests/test_nostr_qr.py @@ -44,7 +44,8 @@ def test_show_qr_for_nostr_keys(monkeypatch): idx = entry_mgr.add_nostr_key("main") npub, _ = entry_mgr.get_nostr_key_pair(idx, TEST_SEED) - monkeypatch.setattr("builtins.input", lambda *a, **k: str(idx)) + inputs = iter([str(idx), "n"]) + monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) responses = iter([True, False]) monkeypatch.setattr( "password_manager.manager.confirm_action", diff --git a/src/tests/test_secret_mode.py b/src/tests/test_secret_mode.py index 7087396..e11f7a2 100644 --- a/src/tests/test_secret_mode.py +++ b/src/tests/test_secret_mode.py @@ -41,7 +41,8 @@ def test_password_retrieve_secret_mode(monkeypatch, capsys): pm, entry_mgr = setup_pm(tmp) entry_mgr.add_entry("example", 8) - monkeypatch.setattr("builtins.input", lambda *a, **k: "0") + inputs = iter(["0", "n"]) + monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) called = [] monkeypatch.setattr( "password_manager.manager.copy_to_clipboard", @@ -89,7 +90,8 @@ def test_password_retrieve_no_secret_mode(monkeypatch, capsys): pm.secret_mode_enabled = False entry_mgr.add_entry("example", 8) - monkeypatch.setattr("builtins.input", lambda *a, **k: "0") + inputs = iter(["0", "n"]) + monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) called = [] monkeypatch.setattr( "password_manager.manager.copy_to_clipboard", From 9659e96fd8370b851fbfcfc0b7dbbe20e8b6f2e7 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 12:51:43 -0400 Subject: [PATCH 10/33] Rename archived menu --- README.md | 2 +- src/main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1ad7ade..583bc7a 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ When choosing **Add Entry**, you can now select **Password**, **2FA (TOTP)**, 2. SeedPass will show the current label, period, digit count, and archived status. 3. Enter new values or press **Enter** to keep the existing settings. 4. The updated entry is saved back to your encrypted vault. -5. Archived entries are hidden from lists but can be restored from the **Archived Entries** menu. +5. Archived entries are hidden from lists but can be restored from the **List Archived** menu. ### Using Secret Mode diff --git a/src/main.py b/src/main.py index 47ab60f..596c61c 100644 --- a/src/main.py +++ b/src/main.py @@ -751,7 +751,7 @@ def display_menu( 6. 2FA Codes 7. Settings 8. Archive Entry - 9. View Archived Entries + 9. List Archived """ display_fn = getattr(password_manager, "display_stats", None) if callable(display_fn): From 59664408647ac94392735d1c140eac7a8b0c916c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 13:27:42 -0400 Subject: [PATCH 11/33] Update tests with archive prompt responses --- src/tests/test_custom_fields_display.py | 2 +- src/tests/test_manager_list_entries.py | 2 +- src/tests/test_manager_search_display.py | 2 +- src/tests/test_nostr_qr.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tests/test_custom_fields_display.py b/src/tests/test_custom_fields_display.py index 50745ae..279e19a 100644 --- a/src/tests/test_custom_fields_display.py +++ b/src/tests/test_custom_fields_display.py @@ -42,7 +42,7 @@ def test_retrieve_entry_shows_custom_fields(monkeypatch, capsys): ], ) - inputs = iter(["0", "y", "n"]) + inputs = iter(["0", "y", "", "n"]) monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) pm.handle_retrieve_entry() diff --git a/src/tests/test_manager_list_entries.py b/src/tests/test_manager_list_entries.py index 5d7dd3a..3bc9e07 100644 --- a/src/tests/test_manager_list_entries.py +++ b/src/tests/test_manager_list_entries.py @@ -74,7 +74,7 @@ def test_list_entries_show_details(monkeypatch, capsys): lambda *a, **k: "b", ) - inputs = iter(["1", "0"]) + inputs = iter(["1", "0", "n"]) monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) pm.handle_list_entries() diff --git a/src/tests/test_manager_search_display.py b/src/tests/test_manager_search_display.py index 16c83c2..3133c65 100644 --- a/src/tests/test_manager_search_display.py +++ b/src/tests/test_manager_search_display.py @@ -41,7 +41,7 @@ def test_search_entries_prompt_for_details(monkeypatch, capsys): monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b") - inputs = iter(["Example", "0", ""]) + inputs = iter(["Example", "0", "n", ""]) monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) pm.handle_search_entries() diff --git a/src/tests/test_nostr_qr.py b/src/tests/test_nostr_qr.py index f967c5c..ccb81e9 100644 --- a/src/tests/test_nostr_qr.py +++ b/src/tests/test_nostr_qr.py @@ -44,7 +44,7 @@ def test_show_qr_for_nostr_keys(monkeypatch): idx = entry_mgr.add_nostr_key("main") npub, _ = entry_mgr.get_nostr_key_pair(idx, TEST_SEED) - inputs = iter([str(idx), "n"]) + inputs = iter([str(idx), "n", ""]) monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) responses = iter([True, False]) monkeypatch.setattr( From f39b4348d1f8b0510f761f175d6095631b8a1a2b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 13:42:13 -0400 Subject: [PATCH 12/33] Add test for archiving via retrieve --- src/tests/test_archive_from_retrieve.py | 48 +++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/tests/test_archive_from_retrieve.py diff --git a/src/tests/test_archive_from_retrieve.py b/src/tests/test_archive_from_retrieve.py new file mode 100644 index 0000000..04b9579 --- /dev/null +++ b/src/tests/test_archive_from_retrieve.py @@ -0,0 +1,48 @@ +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 +from password_manager.config_manager import ConfigManager + + +class FakePasswordGenerator: + def generate_password(self, length: int, index: int) -> str: # noqa: D401 + return "pw" + + +def test_archive_entry_from_retrieve(monkeypatch): + 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.password_generator = FakePasswordGenerator() + pm.parent_seed = TEST_SEED + pm.nostr_client = SimpleNamespace() + pm.fingerprint_dir = tmp_path + pm.secret_mode_enabled = False + + index = entry_mgr.add_entry("example.com", 8) + + inputs = iter([str(index), "y"]) + monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) + + pm.handle_retrieve_entry() + + assert entry_mgr.retrieve_entry(index)["archived"] is True From ec22171ed632ce44fae7367c349cdbf4648c75d9 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 13:59:56 -0400 Subject: [PATCH 13/33] Pause after archive and view archived --- src/password_manager/manager.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 541a94d..b83b9c3 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -2151,6 +2151,7 @@ class PasswordManager: self.entry_manager.archive_entry(index) self.is_dirty = True self.last_update = time.time() + pause() except Exception as e: logging.error(f"Error archiving entry: {e}", exc_info=True) print(colored(f"Error: Failed to archive entry: {e}", "red")) @@ -2162,6 +2163,7 @@ class PasswordManager: archived = [e for e in archived if e[4]] if not archived: print(colored("No archived entries found.", "yellow")) + pause() return while True: clear_and_print_fingerprint( @@ -2183,9 +2185,11 @@ class PasswordManager: self.entry_manager.restore_entry(restore_index) self.is_dirty = True self.last_update = time.time() + pause() archived = [e for e in archived if e[0] != restore_index] if not archived: print(colored("All entries restored.", "green")) + pause() break except Exception as e: logging.error(f"Error viewing archived entries: {e}", exc_info=True) From 0fb09779fdf72da1e813d337b10eb231d167f3bc Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 14:14:37 -0400 Subject: [PATCH 14/33] Fix archived entry listing --- src/password_manager/entry_management.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index 9d4a8ea..7470c10 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -728,7 +728,8 @@ class EntryManager: print(colored(f" Label: {entry.get('label', '')}", "cyan")) print( colored( - f" Derivation Index: {entry.get('index', index)}", "cyan" + f" Derivation Index: {entry.get('index', idx)}", + "cyan", ) ) print("-" * 40) From 0258a79952ba1d5fd0b08fed4115cd9dcee31945 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 14:33:01 -0400 Subject: [PATCH 15/33] Add view option for archived entries --- README.md | 2 +- src/password_manager/manager.py | 47 ++++++++++++++++++++++--------- src/tests/test_archive_restore.py | 35 ++++++++++++++++++++++- 3 files changed, 68 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 583bc7a..0a00a71 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ When choosing **Add Entry**, you can now select **Password**, **2FA (TOTP)**, 2. SeedPass will show the current label, period, digit count, and archived status. 3. Enter new values or press **Enter** to keep the existing settings. 4. The updated entry is saved back to your encrypted vault. -5. Archived entries are hidden from lists but can be restored from the **List Archived** menu. +5. Archived entries are hidden from lists but can be viewed or restored from the **List Archived** menu. ### Using Secret Mode diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index b83b9c3..7ac1cd8 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -2157,7 +2157,7 @@ class PasswordManager: print(colored(f"Error: Failed to archive entry: {e}", "red")) def handle_view_archived_entries(self) -> None: - """Display archived entries and optionally restore one.""" + """Display archived entries and optionally view or restore them.""" try: archived = self.entry_manager.list_entries(include_archived=True) archived = [e for e in archived if e[4]] @@ -2171,26 +2171,45 @@ class PasswordManager: "Main Menu > Archived Entries", ) print(colored("\n[+] Archived Entries:\n", "green")) - for idx, label, username, url, _ in archived: + for idx, label, _username, _url, _ in archived: print(colored(f"{idx}. {label}", "cyan")) idx_input = input( - "Enter index to restore or press Enter to go back: " + "Enter index to manage or press Enter to go back: " ).strip() if not idx_input: break - if not idx_input.isdigit(): + if not idx_input.isdigit() or int(idx_input) not in [ + e[0] for e in archived + ]: print(colored("Invalid index.", "red")) continue - restore_index = int(idx_input) - self.entry_manager.restore_entry(restore_index) - self.is_dirty = True - self.last_update = time.time() - pause() - archived = [e for e in archived if e[0] != restore_index] - if not archived: - print(colored("All entries restored.", "green")) - pause() - break + entry_index = int(idx_input) + while True: + action = ( + input( + "Enter 'v' to view details, 'r' to restore, or press Enter to go back: " + ) + .strip() + .lower() + ) + if action == "v": + self.display_entry_details(entry_index) + pause() + elif action == "r": + self.entry_manager.restore_entry(entry_index) + self.is_dirty = True + self.last_update = time.time() + pause() + archived = [e for e in archived if e[0] != entry_index] + if not archived: + print(colored("All entries restored.", "green")) + pause() + return + break + elif not action: + break + else: + print(colored("Invalid choice.", "red")) except Exception as e: logging.error(f"Error viewing archived entries: {e}", exc_info=True) print(colored(f"Error: Failed to view archived entries: {e}", "red")) diff --git a/src/tests/test_archive_restore.py b/src/tests/test_archive_restore.py index fe963db..d791077 100644 --- a/src/tests/test_archive_restore.py +++ b/src/tests/test_archive_restore.py @@ -74,7 +74,40 @@ def test_view_archived_entries_cli(monkeypatch): pm.handle_archive_entry() assert entry_mgr.retrieve_entry(idx)["archived"] is True - inputs = iter([str(idx), ""]) + inputs = iter([str(idx), "r", "", ""]) monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) pm.handle_view_archived_entries() assert entry_mgr.retrieve_entry(idx)["archived"] is False + + +def test_view_archived_entries_view_only(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 + + idx = entry_mgr.add_entry("example.com", 8) + + monkeypatch.setattr("builtins.input", lambda *_: str(idx)) + pm.handle_archive_entry() + assert entry_mgr.retrieve_entry(idx)["archived"] is True + + inputs = iter([str(idx), "v", "", "", ""]) + monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) + pm.handle_view_archived_entries() + assert entry_mgr.retrieve_entry(idx)["archived"] is True + out = capsys.readouterr().out + assert "example.com" in out From 3228a574ad06f4533ba83747d2b50d33bdc8ec54 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 14:43:20 -0400 Subject: [PATCH 16/33] Remove archive entry option and improve menu --- src/main.py | 10 ++---- src/tests/test_menu_navigation.py | 55 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 8 deletions(-) create mode 100644 src/tests/test_menu_navigation.py diff --git a/src/main.py b/src/main.py index 596c61c..e47998e 100644 --- a/src/main.py +++ b/src/main.py @@ -683,10 +683,8 @@ def handle_settings(password_manager: PasswordManager) -> None: choice = input("Select an option or press Enter to go back: ").strip() if choice == "1": handle_profiles_menu(password_manager) - pause() elif choice == "2": handle_nostr_menu(password_manager) - pause() elif choice == "3": password_manager.change_password() pause() @@ -750,8 +748,7 @@ def display_menu( 5. Modify an Existing Entry 6. 2FA Codes 7. Settings - 8. Archive Entry - 9. List Archived + 8. List Archived """ display_fn = getattr(password_manager, "display_stats", None) if callable(display_fn): @@ -781,7 +778,7 @@ def display_menu( print(color_text(menu, "menu")) try: choice = timed_input( - "Enter your choice (1-9) or press Enter to exit: ", + "Enter your choice (1-8) or press Enter to exit: ", inactivity_timeout, ).strip() except TimeoutError: @@ -857,9 +854,6 @@ def display_menu( password_manager.update_activity() handle_settings(password_manager) elif choice == "8": - password_manager.update_activity() - password_manager.handle_archive_entry() - elif choice == "9": password_manager.update_activity() password_manager.handle_view_archived_entries() else: diff --git a/src/tests/test_menu_navigation.py b/src/tests/test_menu_navigation.py new file mode 100644 index 0000000..8ab7bae --- /dev/null +++ b/src/tests/test_menu_navigation.py @@ -0,0 +1,55 @@ +import time +from types import SimpleNamespace +from pathlib import Path +import sys +import pytest + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +import main + + +def _make_pm(calls): + return SimpleNamespace( + is_dirty=False, + last_update=time.time(), + last_activity=time.time(), + nostr_client=SimpleNamespace(close_client_pool=lambda: None), + handle_add_password=lambda: calls.append("add"), + handle_add_totp=lambda: calls.append("totp"), + handle_add_ssh_key=lambda: calls.append("ssh"), + handle_add_seed=lambda: calls.append("seed"), + handle_add_nostr_key=lambda: calls.append("nostr"), + handle_add_pgp=lambda: calls.append("pgp"), + handle_retrieve_entry=lambda: calls.append("retrieve"), + handle_search_entries=lambda: calls.append("search"), + handle_list_entries=lambda: calls.append("list"), + handle_modify_entry=lambda: calls.append("modify"), + handle_display_totp_codes=lambda: calls.append("show_totp"), + handle_view_archived_entries=lambda: calls.append("view_archived"), + update_activity=lambda: None, + lock_vault=lambda: None, + unlock_vault=lambda: None, + ) + + +def test_navigate_all_main_menu_options(monkeypatch): + calls = [] + pm = _make_pm(calls) + # Sequence through all main menu options then exit + inputs = iter(["1", "2", "3", "4", "5", "6", "7", "8", ""]) + monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) + # Submenus immediately return + monkeypatch.setattr("builtins.input", lambda *_: "") + monkeypatch.setattr(main, "handle_settings", lambda *_: calls.append("settings")) + with pytest.raises(SystemExit): + main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) + assert calls == [ + "retrieve", + "search", + "list", + "modify", + "show_totp", + "settings", + "view_archived", + ] From 467bdd3d04e2b34ed73cc75d6492bd9e2498e7e3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 15:19:42 -0400 Subject: [PATCH 17/33] Add notes support for TOTP entries --- src/password_manager/entry_management.py | 3 +++ src/password_manager/manager.py | 4 ++++ src/tests/test_manager_add_totp.py | 2 ++ src/tests/test_totp_entry.py | 13 +++++++++++++ 4 files changed, 22 insertions(+) diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index 7470c10..c63731f 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -195,6 +195,7 @@ class EntryManager: index: int | None = None, period: int = 30, digits: int = 6, + notes: str = "", ) -> str: """Add a new TOTP entry and return the provisioning URI.""" entry_id = self.get_next_index() @@ -213,6 +214,7 @@ class EntryManager: "period": period, "digits": digits, "archived": archived, + "notes": notes, } else: entry = { @@ -223,6 +225,7 @@ class EntryManager: "period": period, "digits": digits, "archived": archived, + "notes": notes, } data["entries"][str(entry_id)] = entry diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 7ac1cd8..f694ab0 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1001,6 +1001,7 @@ class PasswordManager: colored("Error: Period and digits must be numbers.", "red") ) continue + notes = input("Notes (optional): ").strip() totp_index = self.entry_manager.get_next_totp_index() entry_id = self.entry_manager.get_next_index() uri = self.entry_manager.add_totp( @@ -1009,6 +1010,7 @@ class PasswordManager: index=totp_index, period=int(period), digits=int(digits), + notes=notes, ) secret = TotpManager.derive_secret(self.parent_seed, totp_index) self.is_dirty = True @@ -1043,6 +1045,7 @@ class PasswordManager: secret = raw.upper() period = int(input("Period (default 30): ").strip() or 30) digits = int(input("Digits (default 6): ").strip() or 6) + notes = input("Notes (optional): ").strip() entry_id = self.entry_manager.get_next_index() uri = self.entry_manager.add_totp( label, @@ -1050,6 +1053,7 @@ class PasswordManager: secret=secret, period=period, digits=digits, + notes=notes, ) self.is_dirty = True self.last_update = time.time() diff --git a/src/tests/test_manager_add_totp.py b/src/tests/test_manager_add_totp.py index 8ad618b..0038511 100644 --- a/src/tests/test_manager_add_totp.py +++ b/src/tests/test_manager_add_totp.py @@ -47,6 +47,7 @@ def test_handle_add_totp(monkeypatch, capsys): "Example", # label "", # period "", # digits + "", # notes ] ) monkeypatch.setattr("builtins.input", lambda *args, **kwargs: next(inputs)) @@ -64,5 +65,6 @@ def test_handle_add_totp(monkeypatch, capsys): "period": 30, "digits": 6, "archived": False, + "notes": "", } assert "ID 0" in out diff --git a/src/tests/test_totp_entry.py b/src/tests/test_totp_entry.py index 87f24da..b25d610 100644 --- a/src/tests/test_totp_entry.py +++ b/src/tests/test_totp_entry.py @@ -36,6 +36,7 @@ def test_add_totp_and_get_code(): "period": 30, "digits": 6, "archived": False, + "notes": "", } code = entry_mgr.get_totp_code(0, TEST_SEED, timestamp=0) @@ -74,6 +75,18 @@ def test_add_totp_imported(tmp_path): "period": 30, "digits": 6, "archived": False, + "notes": "", } code = em.get_totp_code(0, timestamp=0) assert code == pyotp.TOTP(secret).at(0) + + +def test_add_totp_with_notes(tmp_path): + vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + em = EntryManager(vault, backup_mgr) + + em.add_totp("NoteLabel", TEST_SEED, notes="some note") + entry = em.retrieve_entry(0) + assert entry["notes"] == "some note" From 97b2f071659714f603f5b61898231b88fa37ba52 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 15:30:44 -0400 Subject: [PATCH 18/33] Fix archived status in entry listing and search --- src/password_manager/entry_management.py | 20 +++++++++-- src/tests/test_archive_nonpassword.py | 42 ++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 src/tests/test_archive_nonpassword.py diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index c63731f..a3479f2 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -694,7 +694,15 @@ class EntryManager: ) ) else: - entries.append((idx, label, None, None, False)) + entries.append( + ( + idx, + label, + None, + None, + entry.get("archived", entry.get("blacklisted", False)), + ) + ) logger.debug(f"Total entries found: {len(entries)}") for idx, entry in filtered_items: @@ -791,7 +799,15 @@ class EntryManager: ) else: if label_match or notes_match: - results.append((int(idx), label, None, None, False)) + results.append( + ( + int(idx), + label, + None, + None, + entry.get("archived", entry.get("blacklisted", False)), + ) + ) return results diff --git a/src/tests/test_archive_nonpassword.py b/src/tests/test_archive_nonpassword.py new file mode 100644 index 0000000..6296813 --- /dev/null +++ b/src/tests/test_archive_nonpassword.py @@ -0,0 +1,42 @@ +from pathlib import Path +from tempfile import TemporaryDirectory +import sys + +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.config_manager import ConfigManager + + +def setup_entry_mgr(tmp_path: Path) -> EntryManager: + vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + return EntryManager(vault, backup_mgr) + + +def test_archive_nonpassword_list_search(): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + em = setup_entry_mgr(tmp_path) + em.add_totp("Example", TEST_SEED) + idx = em.search_entries("Example")[0][0] + + assert em.list_entries() == [(idx, "Example", None, None, False)] + assert em.search_entries("Example") == [(idx, "Example", None, None, False)] + + em.archive_entry(idx) + assert em.retrieve_entry(idx)["archived"] is True + assert em.list_entries() == [] + assert em.list_entries(include_archived=True) == [ + (idx, "Example", None, None, True) + ] + assert em.search_entries("Example") == [(idx, "Example", None, None, True)] + + em.restore_entry(idx) + assert em.retrieve_entry(idx)["archived"] is False + assert em.list_entries() == [(idx, "Example", None, None, False)] + assert em.search_entries("Example") == [(idx, "Example", None, None, False)] From 7ab4d720a26f6e4ae1166b277ceab829098ad1f7 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 16:30:51 -0400 Subject: [PATCH 19/33] Update TOTP example in docs --- docs/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/README.md b/docs/README.md index 42dad0d..dbd0e9a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,10 +4,12 @@ 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. +Run `seedpass totp ` to retrieve a time-based one-time password (TOTP). The +`` can be a label, title, or index. A progress bar shows the remaining +seconds in the current period. ```bash -$ seedpass get-code --index 0 +$ seedpass totp "email" [##########----------] 15s Code: 123456 ``` From 044d1d054d7af2f5a2df51c47d864be19e929554 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 16:41:52 -0400 Subject: [PATCH 20/33] Update JSON entries doc for single index --- docs/json_entries.md | 79 ++++++++++++++++++-------------------------- 1 file changed, 33 insertions(+), 46 deletions(-) diff --git a/docs/json_entries.md b/docs/json_entries.md index 1fd1e0b..a695329 100644 --- a/docs/json_entries.md +++ b/docs/json_entries.md @@ -29,23 +29,29 @@ ## Introduction -**SeedPass** is a secure password generator and manager leveraging **Bitcoin's BIP-85 standard** and integrating with the **Nostr network** for decentralized synchronization. Instead of pushing one large index file, SeedPass posts **snapshot chunks** of the index followed by lightweight **delta events** whenever changes occur. This chunked approach improves reliability and keeps bandwidth usage minimal. To enhance modularity, scalability, and security, SeedPass now manages each password or data entry as a separate JSON file within a **Fingerprint-Based Backup and Local Storage** system. This document outlines the new entry management system, ensuring that new `kind` types can be added seamlessly without disrupting existing functionalities. +**SeedPass** is a secure password generator and manager leveraging **Bitcoin's BIP-85 standard** and integrating with the **Nostr network** for decentralized synchronization. Instead of pushing one large index file, SeedPass posts **snapshot chunks** of the index followed by lightweight **delta events** whenever changes occur. This chunked approach improves reliability and keeps bandwidth usage minimal. To enhance modularity, scalability, and security, SeedPass stores all entries in a single encrypted index file named `seedpass_entries_db.json.enc`. This document outlines the entry management system, ensuring that new `kind` types can be added seamlessly without disrupting existing functionalities. --- ## Index File Format -All entries belonging to a seed profile are summarized in an encrypted file named `seedpass_entries_db.json.enc`. This index starts with `schema_version` `2` and contains an `entries` object keyed by entry numbers. +All entries belonging to a seed profile are stored in an encrypted file named `seedpass_entries_db.json.enc`. This index uses `schema_version` `3` and contains an `entries` object keyed by numeric identifiers. ```json { - "schema_version": 2, + "schema_version": 3, "entries": { "0": { "label": "example.com", "length": 8, + "username": "user", + "url": "https://example.com", + "archived": false, "type": "password", - "notes": "" + "kind": "password", + "notes": "", + "custom_fields": [], + "origin": "" } } } @@ -55,58 +61,39 @@ All entries belonging to a seed profile are summarized in an encrypted file name ## JSON Schema for Individual Entries -Each SeedPass entry is stored as an individual JSON file, promoting isolated management and easy synchronization with Nostr. This structure supports diverse entry types (`kind`) and allows for future expansions. +Each entry is stored within `seedpass_entries_db.json.enc` under the `entries` dictionary. The structure supports diverse entry types (`kind`) and allows for future expansions. ### General Structure ```json { - "entry_num": 0, - "index_num": 0, - "fingerprint": "a1b2c3d4", - "kind": "generated_password", - "data": { - // Fields specific to the kind - }, - "timestamp": "2024-04-27T12:34:56Z", - "metadata": { - "created_at": "2024-04-27T12:34:56Z", - "updated_at": "2024-04-27T12:34:56Z", - "checksum": "" - } + "label": "Example", + "length": 8, + "username": "user@example.com", + "url": "https://example.com", + "archived": false, + "type": "password", + "kind": "password", + "notes": "", + "custom_fields": [], + "origin": "", + "index": 0 } ``` ### Field Descriptions -- **entry_num** (`integer`): Sequential number of the entry starting from 0. Maintains the order of entries. - -- **index_num** (`integer` or `string`): - - For `generated_password` kind: Starts from 0 and increments sequentially. - - For other kinds: A secure random hexadecimal string (e.g., a hash of the content) used as the BIP-85 index. - -- **fingerprint** (`string`): A unique identifier generated from the seed associated with the entry. This fingerprint ensures that each seed's data is isolated and securely managed. - -- **kind** (`string`): Specifies the type of entry. Supported kinds include: - - `password` - - `totp` - - `ssh` - - `seed` - - `pgp` - - `nostr` - - `note` - -- **data** (`object`): Contains fields specific to the `kind`. This allows for extensibility as new kinds are introduced. - -- **timestamp** (`string`): ISO 8601 format timestamp indicating when the entry was created. - -- **metadata** (`object`): - - **created_at** (`string`): ISO 8601 format timestamp of creation. - - **updated_at** (`string`): ISO 8601 format timestamp of the last update. - - **checksum** (`string`): A checksum value to ensure data integrity. - -- **custom_fields** (`array`, optional): A list of user-defined name/value pairs - to store extra information. +- **label** (`string`): Descriptive name for the entry (e.g., website or service). +- **length** (`integer`, optional): Desired password length for generated passwords. +- **username** (`string`, optional): Username associated with the entry. +- **url** (`string`, optional): Website or service URL. +- **archived** (`boolean`): Marks the entry as archived when `true`. +- **type** (`string`): The entry type (`password`, `totp`, `ssh`, `seed`, `pgp`, `nostr`, `note`). +- **kind** (`string`): Synonym for `type` kept for backward compatibility. +- **notes** (`string`): Free-form notes. +- **custom_fields** (`array`, optional): Additional user-defined fields. +- **origin** (`string`, optional): Source identifier for imported data. +- **index** (`integer`, optional): BIP-85 derivation index for entries that derive material from a seed. Example: ```json From 2256694263907f7317e0299d048177d488968ba4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 16:47:07 -0400 Subject: [PATCH 21/33] Update disclaimer with snapshot chunk size note --- landing/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/landing/index.html b/landing/index.html index 90c4649..9c1f04f 100644 --- a/landing/index.html +++ b/landing/index.html @@ -198,6 +198,7 @@ Enter your choice (1-7) or press Enter to exit:

Disclaimer

⚠️ Disclaimer: This software was not developed by an experienced security expert and should be used with caution. There may be bugs and missing features. Additionally, the security of the program's memory management and logs has not been evaluated and may leak sensitive information.

+

Snapshot chunks are limited to 50 KB and rotated when deltas accumulate.

From 0e7684ed52406c4e40975a0e959684beb7593029 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 16:54:14 -0400 Subject: [PATCH 22/33] Add CLI export/import tests --- src/tests/test_cli_export_import.py | 91 +++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 src/tests/test_cli_export_import.py diff --git a/src/tests/test_cli_export_import.py b/src/tests/test_cli_export_import.py new file mode 100644 index 0000000..de11ea4 --- /dev/null +++ b/src/tests/test_cli_export_import.py @@ -0,0 +1,91 @@ +from pathlib import Path +from types import SimpleNamespace + +import sys + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +import main +from password_manager.portable_backup import export_backup, import_backup +from password_manager.config_manager import ConfigManager +from password_manager.backup import BackupManager +from helpers import create_vault, TEST_SEED + + +def _setup_pm(tmp_path: Path): + vault, _ = create_vault(tmp_path, TEST_SEED) + cfg = ConfigManager(vault, tmp_path) + backup = BackupManager(tmp_path, cfg) + pm = SimpleNamespace( + handle_export_database=lambda p: export_backup( + vault, backup, p, parent_seed=TEST_SEED + ), + handle_import_database=lambda p: import_backup( + vault, backup, p, parent_seed=TEST_SEED + ), + nostr_client=SimpleNamespace(close_client_pool=lambda: None), + ) + return pm, vault + + +def test_cli_export_creates_file(monkeypatch, tmp_path): + pm, vault = _setup_pm(tmp_path) + data = { + "schema_version": 3, + "entries": { + "0": { + "label": "example", + "type": "password", + "notes": "", + "custom_fields": [], + "origin": "", + } + }, + } + vault.save_index(data) + + monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "configure_logging", lambda: None) + monkeypatch.setattr(main, "initialize_app", lambda: None) + monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) + + export_path = tmp_path / "out.json" + rc = main.main(["export", "--file", str(export_path)]) + assert rc == 0 + assert export_path.exists() + + +def test_cli_import_round_trip(monkeypatch, tmp_path): + pm, vault = _setup_pm(tmp_path) + original = { + "schema_version": 3, + "entries": { + "0": { + "label": "example", + "type": "password", + "notes": "", + "custom_fields": [], + "origin": "", + } + }, + } + vault.save_index(original) + + export_path = tmp_path / "out.json" + export_backup( + vault, + BackupManager(tmp_path, ConfigManager(vault, tmp_path)), + export_path, + parent_seed=TEST_SEED, + ) + + vault.save_index({"schema_version": 3, "entries": {}}) + + monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "configure_logging", lambda: None) + monkeypatch.setattr(main, "initialize_app", lambda: None) + monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) + + rc = main.main(["import", "--file", str(export_path)]) + assert rc == 0 + assert vault.load_index() == original From b84351eefd59cb7676950909af8854ecba3534b2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 17:06:23 -0400 Subject: [PATCH 23/33] docs: mark advanced CLI as future feature --- docs/README.md | 2 +- docs/advanced_cli.md | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/README.md b/docs/README.md index dbd0e9a..bd25730 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,4 +14,4 @@ $ seedpass totp "email" Code: 123456 ``` -See [advanced_cli.md](advanced_cli.md) for a full command reference. +See [advanced_cli.md](advanced_cli.md) (future feature set) for details on the upcoming advanced CLI. diff --git a/docs/advanced_cli.md b/docs/advanced_cli.md index 2f5aeda..693665a 100644 --- a/docs/advanced_cli.md +++ b/docs/advanced_cli.md @@ -1,8 +1,10 @@ -# Advanced CLI Commands Documentation +# Advanced CLI Commands Documentation (Future Feature Set) ## Overview The **Advanced CLI Commands** document provides an in-depth guide to the various command-line functionalities available in **SeedPass**, a secure password manager built on Bitcoin's BIP-85 standard. Designed for power users and developers, this guide outlines each command's purpose, usage, options, and examples to facilitate efficient and effective password management through the CLI. +> **Note:** This documentation describes planned functionality. The advanced CLI is not yet part of the stable release. It will align with the current SeedPass design, using fingerprint-based profiles and a forthcoming API to allow secure integration for power users and external applications. + --- @@ -845,6 +847,10 @@ seedpass fingerprint rename A1B2C3D4 PersonalProfile - **Nostr Integration:** - Ensure that your Nostr relays are reliable and secure. - Regularly verify your Nostr public key and manage relays through the `set-relays` command. +## Planned API Integration + +SeedPass will expose a local API that allows third-party applications and browser extensions to interact with your encrypted vault. The advanced CLI will act as a power-user client for these endpoints, mapping commands directly to the API while preserving the fingerprint-based security model. This design enables automation and programmatic access without compromising security. + --- From 379263b0b2ad13bc387d223498d301869c16d27a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 18:02:38 -0400 Subject: [PATCH 24/33] Add KEY_VALUE entry type --- src/password_manager/entry_types.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/password_manager/entry_types.py b/src/password_manager/entry_types.py index 5108925..24aa9e7 100644 --- a/src/password_manager/entry_types.py +++ b/src/password_manager/entry_types.py @@ -13,3 +13,4 @@ class EntryType(str, Enum): SEED = "seed" PGP = "pgp" NOSTR = "nostr" + KEY_VALUE = "key_value" From 6ae9f65fe81bd1686dbfffecd598b2d9e9416339 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 18:17:01 -0400 Subject: [PATCH 25/33] Add key-value entry type support --- src/password_manager/entry_management.py | 77 +++++++++++++++++++++--- src/tests/test_key_value_entry.py | 43 +++++++++++++ 2 files changed, 113 insertions(+), 7 deletions(-) create mode 100644 src/tests/test_key_value_entry.py diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index a3479f2..5a2149b 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -367,6 +367,36 @@ class EntryManager: self.backup_manager.create_backup() return index + def add_key_value( + self, + label: str, + value: str, + *, + notes: str = "", + custom_fields=None, + archived: bool = False, + ) -> int: + """Add a new generic key/value entry.""" + + index = self.get_next_index() + + data = self.vault.load_index() + data.setdefault("entries", {}) + data["entries"][str(index)] = { + "type": EntryType.KEY_VALUE.value, + "kind": EntryType.KEY_VALUE.value, + "label": label, + "value": value, + "notes": notes, + "archived": archived, + "custom_fields": custom_fields or [], + } + + self._save_index(data) + self.update_checksum() + self.backup_manager.create_backup() + return index + def get_nostr_key_pair(self, index: int, parent_seed: str) -> tuple[str, str]: """Return the npub and nsec for the specified entry.""" @@ -502,7 +532,8 @@ class EntryManager: entry = data.get("entries", {}).get(str(index)) if entry: - if entry.get("type", entry.get("kind")) == EntryType.PASSWORD.value: + etype = entry.get("type", entry.get("kind")) + if etype in (EntryType.PASSWORD.value, EntryType.KEY_VALUE.value): entry.setdefault("custom_fields", []) logger.debug(f"Retrieved entry at index {index}: {entry}") return entry @@ -531,6 +562,7 @@ class EntryManager: label: Optional[str] = None, period: Optional[int] = None, digits: Optional[int] = None, + value: Optional[str] = None, custom_fields: List[Dict[str, Any]] | None = None, **legacy, ) -> None: @@ -545,6 +577,7 @@ class EntryManager: :param label: (Optional) The new label for the entry. :param period: (Optional) The new TOTP period in seconds. :param digits: (Optional) The new number of digits for TOTP codes. + :param value: (Optional) New value for key/value entries. """ try: data = self.vault.load_index() @@ -578,12 +611,19 @@ class EntryManager: if label is not None: entry["label"] = label logger.debug(f"Updated label to '{label}' for index {index}.") - if username is not None: - entry["username"] = username - logger.debug(f"Updated username to '{username}' for index {index}.") - if url is not None: - entry["url"] = url - logger.debug(f"Updated URL to '{url}' for index {index}.") + if entry_type == EntryType.PASSWORD.value: + if username is not None: + entry["username"] = username + logger.debug( + f"Updated username to '{username}' for index {index}." + ) + if url is not None: + entry["url"] = url + logger.debug(f"Updated URL to '{url}' for index {index}.") + elif entry_type == EntryType.KEY_VALUE.value: + if value is not None: + entry["value"] = value + logger.debug(f"Updated value for index {index}.") if archived is None and "blacklisted" in legacy: archived = legacy["blacklisted"] @@ -797,6 +837,29 @@ class EntryManager: entry.get("archived", entry.get("blacklisted", False)), ) ) + elif etype == EntryType.KEY_VALUE.value: + value_field = str(entry.get("value", "")) + custom_fields = entry.get("custom_fields", []) + custom_match = any( + query_lower in str(cf.get("label", "")).lower() + or query_lower in str(cf.get("value", "")).lower() + for cf in custom_fields + ) + if ( + label_match + or query_lower in value_field.lower() + or notes_match + or custom_match + ): + results.append( + ( + int(idx), + label, + None, + None, + entry.get("archived", entry.get("blacklisted", False)), + ) + ) else: if label_match or notes_match: results.append( diff --git a/src/tests/test_key_value_entry.py b/src/tests/test_key_value_entry.py new file mode 100644 index 0000000..9322d64 --- /dev/null +++ b/src/tests/test_key_value_entry.py @@ -0,0 +1,43 @@ +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.config_manager import ConfigManager + + +def setup_entry_mgr(tmp_path: Path) -> EntryManager: + vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + return EntryManager(vault, backup_mgr) + + +def test_add_and_modify_key_value(): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + em = setup_entry_mgr(tmp_path) + + idx = em.add_key_value("API", "abc123", notes="token") + entry = em.retrieve_entry(idx) + assert entry == { + "type": "key_value", + "kind": "key_value", + "label": "API", + "value": "abc123", + "notes": "token", + "archived": False, + "custom_fields": [], + } + + em.modify_entry(idx, value="def456") + updated = em.retrieve_entry(idx) + assert updated["value"] == "def456" + + results = em.search_entries("def456") + assert results == [(idx, "API", None, None, False)] From 13c98a8cf48f9bbf5892ab9494a9b51979445a64 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 18:25:12 -0400 Subject: [PATCH 26/33] Add key/value entry handlers --- src/password_manager/manager.py | 205 ++++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index f694ab0..2436f5f 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1291,6 +1291,68 @@ class PasswordManager: print(colored(f"Error: Failed to add Nostr key: {e}", "red")) pause() + def handle_add_key_value(self) -> None: + """Add a generic key/value entry.""" + try: + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > Add Entry > Key/Value", + ) + label = input("Label: ").strip() + if not label: + print(colored("Error: Label cannot be empty.", "red")) + return + value = input("Value: ").strip() + notes = input("Notes (optional): ").strip() + + custom_fields: list[dict[str, object]] = [] + while True: + add_field = input("Add custom field? (y/N): ").strip().lower() + if add_field != "y": + break + field_label = input(" Field label: ").strip() + field_value = input(" Field value: ").strip() + hidden = input(" Hidden field? (y/N): ").strip().lower() == "y" + custom_fields.append( + { + "label": field_label, + "value": field_value, + "is_hidden": hidden, + } + ) + + index = self.entry_manager.add_key_value( + label, value, notes=notes, custom_fields=custom_fields + ) + self.is_dirty = True + self.last_update = time.time() + + print(colored(f"\n[+] Key/Value entry added with ID {index}.\n", "green")) + if notes: + print(colored(f"Notes: {notes}", "cyan")) + if self.secret_mode_enabled: + copy_to_clipboard(value, self.clipboard_clear_delay) + print( + colored( + f"[+] Value copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(color_text(f"Value: {value}", "deterministic")) + try: + self.sync_vault() + except Exception as nostr_error: # pragma: no cover - best effort + logging.error( + f"Failed to post updated index to Nostr: {nostr_error}", + exc_info=True, + ) + pause() + except Exception as e: + logging.error(f"Error during key/value setup: {e}", exc_info=True) + print(colored(f"Error: Failed to add key/value entry: {e}", "red")) + pause() + def show_entry_details_by_index(self, index: int) -> None: """Display entry details using :meth:`handle_retrieve_entry` for the given index without prompting for it again.""" @@ -1570,6 +1632,67 @@ class PasswordManager: pause() return + if entry_type == EntryType.KEY_VALUE.value: + label = entry.get("label", "") + value = entry.get("value", "") + notes = entry.get("notes", "") + archived = entry.get("archived", False) + print(colored(f"Retrieving value for '{label}'.", "cyan")) + if notes: + print(colored(f"Notes: {notes}", "cyan")) + print( + colored( + f"Archived Status: {'Archived' if archived else 'Active'}", + "cyan", + ) + ) + if self.secret_mode_enabled: + copy_to_clipboard(value, self.clipboard_clear_delay) + print( + colored( + f"[+] Value copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(color_text(f"Value: {value}", "deterministic")) + + custom_fields = entry.get("custom_fields", []) + if custom_fields: + print(colored("Additional Fields:", "cyan")) + hidden_fields = [] + for field in custom_fields: + f_label = field.get("label", "") + f_value = field.get("value", "") + if field.get("is_hidden"): + hidden_fields.append((f_label, f_value)) + print(colored(f" {f_label}: [hidden]", "cyan")) + else: + print(colored(f" {f_label}: {f_value}", "cyan")) + if hidden_fields: + show = input("Reveal hidden fields? (y/N): ").strip().lower() + if show == "y": + for f_label, f_value in hidden_fields: + if self.secret_mode_enabled: + copy_to_clipboard( + f_value, self.clipboard_clear_delay + ) + print( + colored( + f"[+] {f_label} copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(colored(f" {f_label}: {f_value}", "cyan")) + choice = input("Archive this entry? (y/N): ").strip().lower() + if choice == "y": + self.entry_manager.archive_entry(index) + self.is_dirty = True + self.last_update = time.time() + pause() + return + website_name = entry.get("website") length = entry.get("length") username = entry.get("username") @@ -1792,6 +1915,85 @@ class PasswordManager: digits=new_digits, custom_fields=custom_fields, ) + elif entry_type == EntryType.KEY_VALUE.value: + label = entry.get("label", "") + value = entry.get("value", "") + blacklisted = entry.get("archived", False) + notes = entry.get("notes", "") + + print( + colored( + f"Modifying key/value entry '{label}' (Index: {index}):", + "cyan", + ) + ) + print( + colored( + f"Current Archived Status: {'Archived' if blacklisted else 'Active'}", + "cyan", + ) + ) + new_label = ( + input(f'Enter new label (leave blank to keep "{label}"): ').strip() + or label + ) + new_value = ( + input("Enter new value (leave blank to keep current): ").strip() + or value + ) + blacklist_input = ( + input( + f'Archive this entry? (Y/N, current: {"Y" if blacklisted else "N"}): ' + ) + .strip() + .lower() + ) + if blacklist_input == "": + new_blacklisted = blacklisted + elif blacklist_input == "y": + new_blacklisted = True + elif blacklist_input == "n": + new_blacklisted = False + else: + print( + colored( + "Invalid input for archived status. Keeping the current status.", + "yellow", + ) + ) + new_blacklisted = blacklisted + + new_notes = ( + input( + f'Enter new notes (leave blank to keep "{notes or "N/A"}"): ' + ).strip() + or notes + ) + + edit_fields = input("Edit custom fields? (y/N): ").strip().lower() + custom_fields = None + if edit_fields == "y": + custom_fields = [] + while True: + f_label = input( + " Field label (leave blank to finish): " + ).strip() + if not f_label: + break + f_value = input(" Field value: ").strip() + hidden = input(" Hidden field? (y/N): ").strip().lower() == "y" + custom_fields.append( + {"label": f_label, "value": f_value, "is_hidden": hidden} + ) + + self.entry_manager.modify_entry( + index, + archived=new_blacklisted, + notes=new_notes, + label=new_label, + value=new_value, + custom_fields=custom_fields, + ) else: website_name = entry.get("label", entry.get("website")) username = entry.get("username") @@ -2054,6 +2256,7 @@ class PasswordManager: print(color_text("5. Seed Phrase", "menu")) print(color_text("6. Nostr Key Pair", "menu")) print(color_text("7. PGP", "menu")) + print(color_text("8. Key/Value", "menu")) choice = input("Select entry type or press Enter to go back: ").strip() if choice == "1": filter_kind = None @@ -2069,6 +2272,8 @@ class PasswordManager: filter_kind = EntryType.NOSTR.value elif choice == "7": filter_kind = EntryType.PGP.value + elif choice == "8": + filter_kind = EntryType.KEY_VALUE.value elif not choice: return else: From 64800397e2e49ce4a9d83255bcde16848fbad0c1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 18:31:17 -0400 Subject: [PATCH 27/33] Add Key/Value option and update search display --- src/main.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main.py b/src/main.py index e47998e..d9afad2 100644 --- a/src/main.py +++ b/src/main.py @@ -269,6 +269,8 @@ def print_matches( print(color_text(" Type: PGP Key", "index")) elif etype == EntryType.NOSTR.value: print(color_text(" Type: Nostr Key", "index")) + elif etype == EntryType.KEY_VALUE.value: + print(color_text(" Type: Key/Value", "index")) else: if website: print(color_text(f" Label: {website}", "index")) @@ -805,6 +807,7 @@ def display_menu( print(color_text("4. Seed Phrase", "menu")) print(color_text("5. Nostr Key Pair", "menu")) print(color_text("6. PGP Key", "menu")) + print(color_text("7. Key/Value", "menu")) sub_choice = input( "Select entry type or press Enter to go back: " ).strip() @@ -827,6 +830,9 @@ def display_menu( elif sub_choice == "6": password_manager.handle_add_pgp() break + elif sub_choice == "7": + password_manager.handle_add_key_value() + break elif not sub_choice: break else: From 8e60fdc6c2580c9184b24215beede8bc438eb050 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 18:37:13 -0400 Subject: [PATCH 28/33] docs: document key/value entries --- README.md | 2 ++ docs/json_entries.md | 17 ++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0a00a71..a901aef 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,7 @@ SeedPass supports storing more than just passwords and 2FA secrets. You can also When you retrieve one of these entries, SeedPass can display QR codes for the keys. The `npub` is wrapped in the `nostr:` URI scheme so any client can scan it, while the `nsec` QR is shown only after a security warning. +- **Key/Value** – store a simple key and value for miscellaneous secrets or configuration data. The table below summarizes the extra fields stored for each entry type. Every entry includes a `label`, while only password entries track a `url`. @@ -289,6 +290,7 @@ entry includes a `label`, while only password entries track a `url`. | Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes` | | PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes` | | Nostr Key Pair| `index`, `archived`, optional `notes` | +| Key/Value | `value`, `archived`, optional `notes`, optional `custom_fields` | ### Managing Multiple Seeds diff --git a/docs/json_entries.md b/docs/json_entries.md index a695329..0f2b23d 100644 --- a/docs/json_entries.md +++ b/docs/json_entries.md @@ -88,7 +88,7 @@ Each entry is stored within `seedpass_entries_db.json.enc` under the `entries` d - **username** (`string`, optional): Username associated with the entry. - **url** (`string`, optional): Website or service URL. - **archived** (`boolean`): Marks the entry as archived when `true`. -- **type** (`string`): The entry type (`password`, `totp`, `ssh`, `seed`, `pgp`, `nostr`, `note`). +- **type** (`string`): The entry type (`password`, `totp`, `ssh`, `seed`, `pgp`, `nostr`, `note`, `key_value`). - **kind** (`string`): Synonym for `type` kept for backward compatibility. - **notes** (`string`): Free-form notes. - **custom_fields** (`array`, optional): Additional user-defined fields. @@ -236,6 +236,21 @@ Each entry is stored within `seedpass_entries_db.json.enc` under the `entries` d } ``` +#### 7. Key/Value + +```json +{ + "entry_num": 6, + "fingerprint": "a1b2c3d4", + "kind": "key_value", + "data": { + "key": "api_key", + "value": "" + }, + "timestamp": "2024-04-27T12:40:56Z" +} +``` + --- ## Handling `kind` Types and Extensibility From 28d6f2c6565b7cb509419851f12b8bdb98397ec4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 18:47:01 -0400 Subject: [PATCH 29/33] test: add key/value entry tests --- src/tests/test_entry_add.py | 4 ++++ src/tests/test_manager_list_entries.py | 4 ++++ src/tests/test_search_entries.py | 11 +++++++++++ 3 files changed, 19 insertions(+) diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py index 7bfdb8d..ed2630e 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -53,6 +53,7 @@ def test_add_and_retrieve_entry(): ("add_totp", "totp"), ("add_ssh_key", "ssh"), ("add_seed", "seed"), + ("add_key_value", "key_value"), ], ) def test_round_trip_entry_types(method, expected_type): @@ -67,6 +68,8 @@ def test_round_trip_entry_types(method, expected_type): elif method == "add_totp": entry_mgr.add_totp("example", TEST_SEED) index = 0 + elif method == "add_key_value": + index = entry_mgr.add_key_value("label", "val") else: if method == "add_ssh_key": index = entry_mgr.add_ssh_key("ssh", TEST_SEED) @@ -109,6 +112,7 @@ def test_legacy_entry_defaults_to_password(): ("add_pgp_key", ("pgp", TEST_SEED)), ("add_nostr_key", ("nostr",)), ("add_seed", ("seed", TEST_SEED)), + ("add_key_value", ("label", "val")), ], ) def test_add_default_archived_false(method, args): diff --git a/src/tests/test_manager_list_entries.py b/src/tests/test_manager_list_entries.py index 3bc9e07..abd62dd 100644 --- a/src/tests/test_manager_list_entries.py +++ b/src/tests/test_manager_list_entries.py @@ -33,6 +33,7 @@ def test_handle_list_entries(monkeypatch, capsys): entry_mgr.add_totp("Example", TEST_SEED) entry_mgr.add_entry("example.com", 12) + entry_mgr.add_key_value("API", "abc123") inputs = iter(["1", ""]) # list all, then exit monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) @@ -41,6 +42,7 @@ def test_handle_list_entries(monkeypatch, capsys): out = capsys.readouterr().out assert "Example" in out assert "example.com" in out + assert "API" in out def test_list_entries_show_details(monkeypatch, capsys): @@ -63,6 +65,7 @@ def test_list_entries_show_details(monkeypatch, capsys): pm.secret_mode_enabled = False entry_mgr.add_totp("Example", TEST_SEED) + entry_mgr.add_key_value("API", "val") monkeypatch.setattr(pm.entry_manager, "get_totp_code", lambda *a, **k: "123456") monkeypatch.setattr( @@ -81,3 +84,4 @@ def test_list_entries_show_details(monkeypatch, capsys): out = capsys.readouterr().out assert "Retrieved 2FA Code" in out assert "123456" in out + assert "API" in out diff --git a/src/tests/test_search_entries.py b/src/tests/test_search_entries.py index 7f133ed..1d24c76 100644 --- a/src/tests/test_search_entries.py +++ b/src/tests/test_search_entries.py @@ -86,6 +86,17 @@ def test_search_by_custom_field(): assert result == [(idx, "Example", "", "", False)] +def test_search_key_value_value(): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + entry_mgr = setup_entry_manager(tmp_path) + + idx = entry_mgr.add_key_value("API", "token123") + + result = entry_mgr.search_entries("token123") + assert result == [(idx, "API", None, None, False)] + + def test_search_no_results(): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) From d9974ed6d4fc01d666c9b5a5b70d3af85a3479f8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 18:58:19 -0400 Subject: [PATCH 30/33] docs: describe key/value entries --- docs/advanced_cli.md | 35 +++++++++++++++++++++++++++++------ docs/json_entries.md | 5 +++++ landing/index.html | 7 +++++-- 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/docs/advanced_cli.md b/docs/advanced_cli.md index 693665a..6694314 100644 --- a/docs/advanced_cli.md +++ b/docs/advanced_cli.md @@ -38,9 +38,10 @@ The **Advanced CLI Commands** document provides an in-depth guide to the various - [24. Show All Passwords](#22-show-all-passwords) - [23. Add Notes to an Entry](#23-add-notes-to-an-entry) - [24. Add Tags to an Entry](#24-add-tags-to-an-entry) - - [25. Search by Tag or Title](#25-search-by-tag-or-title) - - [26. Automatically Post Deltas to Nostr After Edit](#26-automatically-post-deltas-to-nostr-after-edit) - - [27. Initial Setup Prompt for Seed Generation/Import](#27-initial-setup-prompt-for-seed-generationimport) + - [25. Add Key/Value Entry](#25-add-keyvalue-entry) + - [26. Search by Tag or Title](#26-search-by-tag-or-title) + - [27. Automatically Post Deltas to Nostr After Edit](#27-automatically-post-deltas-to-nostr-after-edit) + - [28. Initial Setup Prompt for Seed Generation/Import](#28-initial-setup-prompt-for-seed-generationimport) 3. [Notes on New CLI Commands](#notes-on-new-cli-commands) --- @@ -78,6 +79,7 @@ The following table provides a quick reference to all available advanced CLI com | Show All Passwords | `show-all` | `-SA` | `--show-all` | `seedpass show-all` | | Add Notes to an Entry | `add-notes` | `-AN` | `--add-notes` | `seedpass add-notes --index 3 --notes "This is a secured account"` | | Add Tags to an Entry | `add-tags` | `-AT` | `--add-tags` | `seedpass add-tags --index 3 --tags "personal,finance"` | +| Add Key/Value entry | `add-kv` | `-KV` | `--add-kv` | `seedpass add-kv --label "API" --value "secret"` | Search by Tag or Title | `search-by` | `-SB` | `--search-by` | `seedpass search-by --tag "work"` or `seedpass search-by --title "GitHub"` | | Automatically Post Deltas After Edit | `auto-post` | `-AP` | `--auto-post` | `seedpass auto-post --enable` or `seedpass auto-post --disable` | | Initial Setup Prompt for Seed Generation/Import | `setup` | `-ST` | `--setup` | `seedpass setup` | @@ -578,7 +580,28 @@ seedpass add-tags --index 3 --tags "personal,finance" --- -### 25. Search by Tag or Title +### 25. Add Key/Value Entry + +**Command:** `add-kv` +**Short Flag:** `-KV` +**Long Flag:** `--add-kv` + +**Description:** +Creates a simple key/value entry for storing items like API keys or configuration snippets. The value can be copied to the clipboard when secret mode is enabled. + +**Usage Example:** +```bash +seedpass add-kv --label "API" --value "secret" --notes "Service token" +``` + +**Options:** +- `--label` (`-L`): Descriptive label for the entry. +- `--value` (`-V`): The secret value to store. +- `--notes` (`-N`): Optional notes about the entry. + +--- + +### 26. Search by Tag or Title **Command:** `search-by` **Short Flag:** `-SB` @@ -599,7 +622,7 @@ seedpass search-by --title "GitHub" --- -### 26. Automatically Post Deltas to Nostr After Edit +### 27. Automatically Post Deltas to Nostr After Edit **Command:** `auto-post` **Short Flag:** `-AP` @@ -620,7 +643,7 @@ seedpass auto-post --disable --- -### 27. Initial Setup Prompt for Seed Generation/Import +### 28. Initial Setup Prompt for Seed Generation/Import **Command:** `setup` **Short Flag:** `-ST` diff --git a/docs/json_entries.md b/docs/json_entries.md index 0f2b23d..263f0ea 100644 --- a/docs/json_entries.md +++ b/docs/json_entries.md @@ -93,6 +93,7 @@ Each entry is stored within `seedpass_entries_db.json.enc` under the `entries` d - **notes** (`string`): Free-form notes. - **custom_fields** (`array`, optional): Additional user-defined fields. - **origin** (`string`, optional): Source identifier for imported data. +- **value** (`string`, optional): For `key_value` entries, stores the secret value. - **index** (`integer`, optional): BIP-85 derivation index for entries that derive material from a seed. Example: @@ -251,6 +252,10 @@ Each entry is stored within `seedpass_entries_db.json.enc` under the `entries` d } ``` +The `key` field is purely descriptive, while `value` holds the sensitive string +such as an API token. Notes and custom fields may also be included alongside the +standard metadata. + --- ## Handling `kind` Types and Extensibility diff --git a/landing/index.html b/landing/index.html index 9c1f04f..fe8958c 100644 --- a/landing/index.html +++ b/landing/index.html @@ -69,6 +69,7 @@ flowchart TB seed --> pgp["🔒 PGP Key"] seed --> mn["🌱 Seed Phrase"] seed --> nostr["⚡ Nostr Keys"] + seed --> kv["🔑 Key/Value"] classDef default fill:#ffffff,stroke:#e94a39,stroke-width:2px,color:#283c4f; Get Started @@ -110,13 +111,14 @@ flowchart TD end A["Parent Seed
(BIP-39 Mnemonic)"] --> B["Seed Bytes
(BIP-39 → 512-bit)"] B --> C["BIP-85 Derivation
(local_bip85.BIP85)"] - C --> D1["Password Entropy
(password_generation)"] & D2["TOTP Secret
(utils.key_derivation.derive_totp_secret)"] & D3["SSH Key Entropy
(password_generation.derive_ssh_key)"] & D4["PGP Key Entropy
(entry_management.add_pgp_key)"] & D5["Child Mnemonic
(BIP-85 derive_mnemonic)"] & D6["Nostr Key Entropy
(nostr.KeyManager)"] + C --> D1["Password Entropy
(password_generation)"] & D2["TOTP Secret
(utils.key_derivation.derive_totp_secret)"] & D3["SSH Key Entropy
(password_generation.derive_ssh_key)"] & D4["PGP Key Entropy
(entry_management.add_pgp_key)"] & D5["Child Mnemonic
(BIP-85 derive_mnemonic)"] & D6["Nostr Key Entropy
(nostr.KeyManager)"] & D7["Key/Value Data
(entry_management.add_key_value)"] D1 --> E1["Passwords"] D2 --> E2["2FA Codes"] D3 --> E3["SSH Key Pair"] D4 --> E4["PGP Key"] D5 --> E5["Seed Phrase"] D6 --> E6["Nostr Keys
(npub / nsec)"] + D7 --> E7["Key/Value"] E1 --> V E2 --> V E3 --> V @@ -129,7 +131,7 @@ flowchart TD R3 --> R4 R4 --> V A -. "Same seed ⇒ re-derive any artifact on demand" .- E1 - A -.-> E2 & E3 & E4 & E5 & E6 + A -.-> E2 & E3 & E4 & E5 & E6 & E7 @@ -145,6 +147,7 @@ flowchart TD
  • Checksum verification to ensure script integrity
  • Interactive TUI for managing entries and settings
  • Issue or import TOTP secrets for 2FA
  • +
  • Store arbitrary secrets as key/value pairs
  • Export your 2FA codes to an encrypted file
  • Optional external backup location
  • Auto-lock after inactivity
  • From 0ee4b5cfb091045f46d87dec66faa3d5202ccf21 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 19:24:42 -0400 Subject: [PATCH 31/33] Improve archive toggle logic on retrieval --- src/password_manager/manager.py | 59 ++++++++++--------------- src/tests/test_archive_from_retrieve.py | 32 ++++++++++++++ 2 files changed, 56 insertions(+), 35 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 2436f5f..2ea9b56 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1373,6 +1373,23 @@ class PasswordManager: finally: builtins.input = original_input + def _prompt_toggle_archive(self, entry: dict, index: int) -> None: + """Prompt the user to archive or restore ``entry`` based on its status.""" + archived = entry.get("archived", entry.get("blacklisted", False)) + prompt = ( + "Restore this entry from archive? (y/N): " + if archived + else "Archive this entry? (y/N): " + ) + choice = input(prompt).strip().lower() + if choice == "y": + if archived: + self.entry_manager.restore_entry(index) + else: + self.entry_manager.archive_entry(index) + self.is_dirty = True + self.last_update = time.time() + def handle_retrieve_entry(self) -> None: """ Handles retrieving a password from the index by prompting the user for the index number @@ -1453,11 +1470,7 @@ class PasswordManager: 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")) - choice = input("Archive this entry? (y/N): ").strip().lower() - if choice == "y": - self.entry_manager.archive_entry(index) - self.is_dirty = True - self.last_update = time.time() + self._prompt_toggle_archive(entry, index) pause() return if entry_type == EntryType.SSH.value: @@ -1493,11 +1506,7 @@ class PasswordManager: except Exception as e: logging.error(f"Error deriving SSH key pair: {e}", exc_info=True) print(colored(f"Error: Failed to derive SSH keys: {e}", "red")) - choice = input("Archive this entry? (y/N): ").strip().lower() - if choice == "y": - self.entry_manager.archive_entry(index) - self.is_dirty = True - self.last_update = time.time() + self._prompt_toggle_archive(entry, index) pause() return if entry_type == EntryType.SEED.value: @@ -1548,11 +1557,7 @@ class PasswordManager: except Exception as e: logging.error(f"Error deriving seed phrase: {e}", exc_info=True) print(colored(f"Error: Failed to derive seed phrase: {e}", "red")) - choice = input("Archive this entry? (y/N): ").strip().lower() - if choice == "y": - self.entry_manager.archive_entry(index) - self.is_dirty = True - self.last_update = time.time() + self._prompt_toggle_archive(entry, index) pause() return if entry_type == EntryType.PGP.value: @@ -1586,11 +1591,7 @@ class PasswordManager: except Exception as e: logging.error(f"Error deriving PGP key: {e}", exc_info=True) print(colored(f"Error: Failed to derive PGP key: {e}", "red")) - choice = input("Archive this entry? (y/N): ").strip().lower() - if choice == "y": - self.entry_manager.archive_entry(index) - self.is_dirty = True - self.last_update = time.time() + self._prompt_toggle_archive(entry, index) pause() return if entry_type == EntryType.NOSTR.value: @@ -1624,11 +1625,7 @@ class PasswordManager: except Exception as e: logging.error(f"Error deriving Nostr keys: {e}", exc_info=True) print(colored(f"Error: Failed to derive Nostr keys: {e}", "red")) - choice = input("Archive this entry? (y/N): ").strip().lower() - if choice == "y": - self.entry_manager.archive_entry(index) - self.is_dirty = True - self.last_update = time.time() + self._prompt_toggle_archive(entry, index) pause() return @@ -1685,11 +1682,7 @@ class PasswordManager: ) else: print(colored(f" {f_label}: {f_value}", "cyan")) - choice = input("Archive this entry? (y/N): ").strip().lower() - if choice == "y": - self.entry_manager.archive_entry(index) - self.is_dirty = True - self.last_update = time.time() + self._prompt_toggle_archive(entry, index) pause() return @@ -1777,11 +1770,7 @@ class PasswordManager: print(colored(f" {label}: {value}", "cyan")) else: print(colored("Error: Failed to retrieve the password.", "red")) - choice = input("Archive this entry? (y/N): ").strip().lower() - if choice == "y": - self.entry_manager.archive_entry(index) - self.is_dirty = True - self.last_update = time.time() + self._prompt_toggle_archive(entry, index) pause() except Exception as e: logging.error(f"Error during password retrieval: {e}", exc_info=True) diff --git a/src/tests/test_archive_from_retrieve.py b/src/tests/test_archive_from_retrieve.py index 04b9579..2383503 100644 --- a/src/tests/test_archive_from_retrieve.py +++ b/src/tests/test_archive_from_retrieve.py @@ -46,3 +46,35 @@ def test_archive_entry_from_retrieve(monkeypatch): pm.handle_retrieve_entry() assert entry_mgr.retrieve_entry(index)["archived"] is True + + +def test_restore_entry_from_retrieve(monkeypatch): + """Archived entries should restore when retrieved and confirmed.""" + 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.password_generator = FakePasswordGenerator() + pm.parent_seed = TEST_SEED + pm.nostr_client = SimpleNamespace() + pm.fingerprint_dir = tmp_path + pm.secret_mode_enabled = False + + index = entry_mgr.add_entry("example.com", 8) + entry_mgr.archive_entry(index) + + inputs = iter([str(index), "y"]) + monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) + + pm.handle_retrieve_entry() + + assert entry_mgr.retrieve_entry(index)["archived"] is False From ecc235427bd063a71e09077a1748a9007c1a6dba Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 19:37:08 -0400 Subject: [PATCH 32/33] Prompt to unarchive after editing --- README.md | 1 + src/password_manager/manager.py | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/README.md b/README.md index a901aef..aa88e8d 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,7 @@ When choosing **Add Entry**, you can now select **Password**, **2FA (TOTP)**, 3. Enter new values or press **Enter** to keep the existing settings. 4. The updated entry is saved back to your encrypted vault. 5. Archived entries are hidden from lists but can be viewed or restored from the **List Archived** menu. +6. When editing an archived entry you'll be prompted to restore it after saving your changes. ### Using Secret Mode diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 2ea9b56..917528f 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -2096,6 +2096,11 @@ class PasswordManager: exc_info=True, ) + updated_entry = self.entry_manager.retrieve_entry(index) + if updated_entry: + self._prompt_toggle_archive(updated_entry, index) + pause() + except Exception as e: logging.error(f"Error during modifying entry: {e}", exc_info=True) print(colored(f"Error: Failed to modify entry: {e}", "red")) From b02a85a3a132e64ee8756d73297aac7e09f74152 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 19:47:09 -0400 Subject: [PATCH 33/33] fix archived list after restore --- src/password_manager/manager.py | 5 ++++- src/tests/test_archive_restore.py | 36 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 917528f..4698272 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -2403,7 +2403,10 @@ class PasswordManager: self.is_dirty = True self.last_update = time.time() pause() - archived = [e for e in archived if e[0] != entry_index] + archived = self.entry_manager.list_entries( + include_archived=True + ) + archived = [e for e in archived if e[4]] if not archived: print(colored("All entries restored.", "green")) pause() diff --git a/src/tests/test_archive_restore.py b/src/tests/test_archive_restore.py index d791077..00225e1 100644 --- a/src/tests/test_archive_restore.py +++ b/src/tests/test_archive_restore.py @@ -111,3 +111,39 @@ def test_view_archived_entries_view_only(monkeypatch, capsys): assert entry_mgr.retrieve_entry(idx)["archived"] is True out = capsys.readouterr().out assert "example.com" in out + + +def test_view_archived_entries_removed_after_restore(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 + + idx = entry_mgr.add_entry("example.com", 8) + + monkeypatch.setattr("builtins.input", lambda *_: str(idx)) + pm.handle_archive_entry() + assert entry_mgr.retrieve_entry(idx)["archived"] is True + + inputs = iter([str(idx), "r", "", ""]) + monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) + pm.handle_view_archived_entries() + assert entry_mgr.retrieve_entry(idx)["archived"] is False + + monkeypatch.setattr("builtins.input", lambda *_: "") + pm.handle_view_archived_entries() + out = capsys.readouterr().out + assert "No archived entries found." in out