From b176486ca77ef71263883cf83a76e292c4b4f0f1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 4 Jul 2025 17:25:02 -0400 Subject: [PATCH] Document integration steps and add SSH/seed UI --- AGENTS.md | 34 +++++++++++++++ README.md | 3 +- src/main.py | 10 ++++- src/password_manager/entry_management.py | 51 ++++++++++++++++------ src/password_manager/manager.py | 55 ++++++++++++++++++++++++ src/tests/test_backup_restore.py | 16 ++++++- src/tests/test_cli_invalid_input.py | 2 +- src/tests/test_entry_add.py | 3 ++ src/tests/test_manager_add_totp.py | 1 + src/tests/test_ssh_entry.py | 2 +- src/tests/test_totp_entry.py | 2 + 11 files changed, 161 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e276706..2671cec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,3 +38,37 @@ This project is written in **Python**. Follow these instructions when working wi - Review code for potential information leaks (e.g., verbose logging) before submitting. Following these practices helps keep the code base consistent and secure. + +## Integrating New Entry Types + +SeedPass supports multiple `kind` values in its JSON entry files. When adding a +new `kind` (for example, SSH keys or BIP‑39 seeds) use the checklist below: + +1. **Menu Updates** – Extend the CLI menus in `main.py` so "Add Entry" offers + choices for the new types and retrieval operations handle them properly. The + current main menu looks like this: + + ``` + Select an option: + 1. Add Entry + 2. Retrieve Entry + 3. Search Entries + 4. Modify an Existing Entry + 5. 2FA Codes + 6. Settings + 7. Exit + ``` + +2. **JSON Schema** – Each entry file must include a `kind` field describing the + entry type. Add new values (`ssh`, `seed`, etc.) as needed and implement + handlers so older kinds continue to work. + +3. **Best Practices** – When introducing a new `kind`, follow the modular + architecture guidelines from `docs/json_entries.md`: + - Use clear, descriptive names. + - Keep handler code for each `kind` separate. + - Validate required fields and gracefully handle missing data. + - Add regression tests to ensure backward compatibility. + +This procedure keeps the UI consistent and ensures new data types integrate +smoothly with existing functionality. diff --git a/README.md b/README.md index 09b2610..743ec9d 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,8 @@ python src/main.py Enter your choice (1-7): ``` - When choosing **Add Entry**, you can now select **Password** or **2FA (TOTP)**. + When choosing **Add Entry**, you can now select **Password**, **2FA (TOTP)**, + **SSH Key**, or **Seed Phrase**. ### Adding a 2FA Entry diff --git a/src/main.py b/src/main.py index f8bf76c..43ff691 100644 --- a/src/main.py +++ b/src/main.py @@ -729,7 +729,9 @@ def display_menu( print("\nAdd Entry:") print("1. Password") print("2. 2FA (TOTP)") - print("3. Back") + print("3. SSH Key") + print("4. Seed Phrase") + print("5. Back") sub_choice = input("Select entry type: ").strip() password_manager.update_activity() if sub_choice == "1": @@ -739,6 +741,12 @@ def display_menu( password_manager.handle_add_totp() break elif sub_choice == "3": + password_manager.handle_add_ssh_key() + break + elif sub_choice == "4": + password_manager.handle_add_seed() + break + elif sub_choice == "5": break else: print(colored("Invalid choice.", "red")) diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index 6fab0a5..c39ebbb 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -58,9 +58,13 @@ class EntryManager: if self.index_file.exists(): try: data = self.vault.load_index() - # Ensure legacy entries without a type are treated as passwords + # Normalize legacy fields for entry in data.get("entries", {}).values(): - entry.setdefault("type", EntryType.PASSWORD.value) + if "type" not in entry and "kind" in entry: + entry["type"] = entry["kind"] + if "kind" not in entry: + entry["kind"] = entry.get("type", EntryType.PASSWORD.value) + entry.setdefault("type", entry["kind"]) logger.debug("Index loaded successfully.") return data except Exception as e: @@ -132,6 +136,7 @@ class EntryManager: "url": url if url else "", "blacklisted": blacklisted, "type": EntryType.PASSWORD.value, + "kind": EntryType.PASSWORD.value, "notes": notes, } @@ -158,7 +163,10 @@ class EntryManager: indices = [ int(v.get("index", 0)) for v in entries.values() - if v.get("type") == EntryType.TOTP.value + if ( + v.get("type") == EntryType.TOTP.value + or v.get("kind") == EntryType.TOTP.value + ) ] return (max(indices) + 1) if indices else 0 @@ -183,6 +191,7 @@ class EntryManager: secret = TotpManager.derive_secret(parent_seed, index) entry = { "type": EntryType.TOTP.value, + "kind": EntryType.TOTP.value, "label": label, "index": index, "period": period, @@ -191,6 +200,7 @@ class EntryManager: else: entry = { "type": EntryType.TOTP.value, + "kind": EntryType.TOTP.value, "label": label, "secret": secret, "period": period, @@ -226,6 +236,7 @@ class EntryManager: data.setdefault("entries", {}) data["entries"][str(index)] = { "type": EntryType.SSH.value, + "kind": EntryType.SSH.value, "index": index, "notes": notes, } @@ -238,7 +249,9 @@ class EntryManager: """Return the PEM formatted SSH key pair for the given entry.""" entry = self.retrieve_entry(index) - if not entry or entry.get("type") != EntryType.SSH.value: + etype = entry.get("type") if entry else None + kind = entry.get("kind") if entry else None + if not entry or (etype != EntryType.SSH.value and kind != EntryType.SSH.value): raise ValueError("Entry is not an SSH key entry") from password_manager.password_generation import derive_ssh_key_pair @@ -262,6 +275,7 @@ class EntryManager: data.setdefault("entries", {}) data["entries"][str(index)] = { "type": EntryType.SEED.value, + "kind": EntryType.SEED.value, "index": index, "words": words_num, "notes": notes, @@ -275,7 +289,11 @@ class EntryManager: """Return the mnemonic seed phrase for the given entry.""" entry = self.retrieve_entry(index) - if not entry or entry.get("type") != EntryType.SEED.value: + etype = entry.get("type") if entry else None + kind = entry.get("kind") if entry else None + if not entry or ( + etype != EntryType.SEED.value and kind != EntryType.SEED.value + ): raise ValueError("Entry is not a seed entry") from password_manager.password_generation import derive_seed_phrase @@ -294,7 +312,11 @@ class EntryManager: ) -> str: """Return the current TOTP code for the specified entry.""" entry = self.retrieve_entry(index) - if not entry or entry.get("type") != EntryType.TOTP.value: + etype = entry.get("type") if entry else None + kind = entry.get("kind") if entry else None + if not entry or ( + etype != EntryType.TOTP.value and kind != EntryType.TOTP.value + ): raise ValueError("Entry is not a TOTP entry") if "secret" in entry: return TotpManager.current_code_from_secret(entry["secret"], timestamp) @@ -306,7 +328,11 @@ class EntryManager: def get_totp_time_remaining(self, index: int) -> int: """Return seconds remaining in the TOTP period for the given entry.""" entry = self.retrieve_entry(index) - if not entry or entry.get("type") != EntryType.TOTP.value: + etype = entry.get("type") if entry else None + kind = entry.get("kind") if entry else None + if not entry or ( + etype != EntryType.TOTP.value and kind != EntryType.TOTP.value + ): raise ValueError("Entry is not a TOTP entry") period = int(entry.get("period", 30)) @@ -395,7 +421,7 @@ class EntryManager: ) return - entry_type = entry.get("type", EntryType.PASSWORD.value) + entry_type = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) if entry_type == EntryType.TOTP.value: if label is not None: @@ -472,14 +498,15 @@ class EntryManager: for idx_str, entry in sorted_items: if ( filter_kind is not None - and entry.get("type", EntryType.PASSWORD.value) != filter_kind + and entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) + != filter_kind ): continue filtered_items.append((int(idx_str), entry)) entries: List[Tuple[int, str, Optional[str], Optional[str], bool]] = [] for idx, entry in filtered_items: - etype = entry.get("type", EntryType.PASSWORD.value) + etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) if etype == EntryType.TOTP.value: entries.append((idx, entry.get("label", ""), None, None, False)) else: @@ -495,7 +522,7 @@ class EntryManager: logger.debug(f"Total entries found: {len(entries)}") for idx, entry in filtered_items: - etype = entry.get("type", EntryType.PASSWORD.value) + etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) print(colored(f"Index: {idx}", "cyan")) if etype == EntryType.TOTP.value: print(colored(" Type: TOTP", "cyan")) @@ -542,7 +569,7 @@ class EntryManager: results: List[Tuple[int, str, Optional[str], Optional[str], bool]] = [] for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])): - etype = entry.get("type", EntryType.PASSWORD.value) + etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) if etype == EntryType.TOTP.value: label = entry.get("label", "") notes = entry.get("notes", "") diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index bd607ad..126540d 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1021,6 +1021,61 @@ class PasswordManager: logging.error(f"Error during TOTP setup: {e}", exc_info=True) print(colored(f"Error: Failed to add TOTP: {e}", "red")) + def handle_add_ssh_key(self) -> None: + """Add an SSH key pair entry and display the derived keys.""" + try: + notes = input("Notes (optional): ").strip() + index = self.entry_manager.add_ssh_key(self.parent_seed, notes=notes) + priv_pem, pub_pem = self.entry_manager.get_ssh_key_pair( + index, self.parent_seed + ) + self.is_dirty = True + self.last_update = time.time() + print(colored(f"\n[+] SSH key entry added with ID {index}.\n", "green")) + print(colored("Public Key:", "cyan")) + print(pub_pem) + print(colored("Private Key:", "cyan")) + print(priv_pem) + try: + self.sync_vault() + except Exception as nostr_error: + logging.error( + f"Failed to post updated index to Nostr: {nostr_error}", + exc_info=True, + ) + except Exception as e: + logging.error(f"Error during SSH key setup: {e}", exc_info=True) + print(colored(f"Error: Failed to add SSH key: {e}", "red")) + + def handle_add_seed(self) -> None: + """Add a derived BIP-39 seed phrase entry.""" + try: + words_input = input("Word count (12 or 24, default 24): ").strip() + notes = input("Notes (optional): ").strip() + if words_input and words_input not in {"12", "24"}: + print(colored("Invalid word count. Choose 12 or 24.", "red")) + return + words = int(words_input) if words_input else 24 + index = self.entry_manager.add_seed( + self.parent_seed, words_num=words, notes=notes + ) + phrase = self.entry_manager.get_seed_phrase(index, self.parent_seed) + self.is_dirty = True + self.last_update = time.time() + print(colored(f"\n[+] Seed entry added with ID {index}.\n", "green")) + print(colored("Seed Phrase:", "cyan")) + print(colored(phrase, "yellow")) + try: + self.sync_vault() + except Exception as nostr_error: + logging.error( + f"Failed to post updated index to Nostr: {nostr_error}", + exc_info=True, + ) + except Exception as e: + logging.error(f"Error during seed phrase setup: {e}", exc_info=True) + print(colored(f"Error: Failed to add seed phrase: {e}", "red")) + def handle_retrieve_entry(self) -> None: """ Handles retrieving a password from the index by prompting the user for the index number diff --git a/src/tests/test_backup_restore.py b/src/tests/test_backup_restore.py index 5c223fb..312f14a 100644 --- a/src/tests/test_backup_restore.py +++ b/src/tests/test_backup_restore.py @@ -24,7 +24,13 @@ def test_backup_restore_workflow(monkeypatch): data1 = { "schema_version": 2, "entries": { - "0": {"website": "a", "length": 10, "type": "password", "notes": ""} + "0": { + "website": "a", + "length": 10, + "type": "password", + "kind": "password", + "notes": "", + } }, } vault.save_index(data1) @@ -39,7 +45,13 @@ def test_backup_restore_workflow(monkeypatch): data2 = { "schema_version": 2, "entries": { - "0": {"website": "b", "length": 12, "type": "password", "notes": ""} + "0": { + "website": "b", + "length": 12, + "type": "password", + "kind": "password", + "notes": "", + } }, } vault.save_index(data2) diff --git a/src/tests/test_cli_invalid_input.py b/src/tests/test_cli_invalid_input.py index df64494..ec225fc 100644 --- a/src/tests/test_cli_invalid_input.py +++ b/src/tests/test_cli_invalid_input.py @@ -77,7 +77,7 @@ def test_out_of_range_menu(monkeypatch, capsys): def test_invalid_add_entry_submenu(monkeypatch, capsys): called = {"add": False, "retrieve": False, "modify": False} pm, _ = _make_pm(called) - inputs = iter(["1", "4", "3", "7"]) + inputs = iter(["1", "6", "5", "7"]) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) with pytest.raises(SystemExit): diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py index 75fecab..8f6eb52 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -31,6 +31,7 @@ def test_add_and_retrieve_entry(): "url": "", "blacklisted": False, "type": "password", + "kind": "password", "notes": "", } @@ -70,8 +71,10 @@ def test_round_trip_entry_types(method, expected_type): entry = entry_mgr.retrieve_entry(index) assert entry["type"] == expected_type + assert entry["kind"] == expected_type data = enc_mgr.load_json_data(entry_mgr.index_file) assert data["entries"][str(index)]["type"] == expected_type + assert data["entries"][str(index)]["kind"] == expected_type def test_legacy_entry_defaults_to_password(): diff --git a/src/tests/test_manager_add_totp.py b/src/tests/test_manager_add_totp.py index 56bec5a..4b159ad 100644 --- a/src/tests/test_manager_add_totp.py +++ b/src/tests/test_manager_add_totp.py @@ -58,6 +58,7 @@ def test_handle_add_totp(monkeypatch, capsys): entry = entry_mgr.retrieve_entry(0) assert entry == { "type": "totp", + "kind": "totp", "label": "Example", "index": 0, "period": 30, diff --git a/src/tests/test_ssh_entry.py b/src/tests/test_ssh_entry.py index d69b4d3..8873691 100644 --- a/src/tests/test_ssh_entry.py +++ b/src/tests/test_ssh_entry.py @@ -22,7 +22,7 @@ def test_add_and_retrieve_ssh_key_pair(): index = entry_mgr.add_ssh_key(TEST_SEED) entry = entry_mgr.retrieve_entry(index) - assert entry == {"type": "ssh", "index": index, "notes": ""} + assert entry == {"type": "ssh", "kind": "ssh", "index": index, "notes": ""} priv1, pub1 = entry_mgr.get_ssh_key_pair(index, TEST_SEED) priv2, pub2 = 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 4051505..2b6b301 100644 --- a/src/tests/test_totp_entry.py +++ b/src/tests/test_totp_entry.py @@ -30,6 +30,7 @@ def test_add_totp_and_get_code(): entry = entry_mgr.retrieve_entry(0) assert entry == { "type": "totp", + "kind": "totp", "label": "Example", "index": 0, "period": 30, @@ -66,6 +67,7 @@ def test_add_totp_imported(tmp_path): entry = em.retrieve_entry(0) assert entry == { "type": "totp", + "kind": "totp", "label": "Imported", "secret": secret, "period": 30,