From 602e49c659044be1e1e763db41b90a7c9705978b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 4 Jul 2025 16:56:54 -0400 Subject: [PATCH] feat: add ssh and seed entry support --- README.md | 4 +- src/main.py | 10 +- src/password_manager/entry_management.py | 69 ++++++++-- src/password_manager/manager.py | 134 +++++++++++++++++++- src/tests/test_cli_invalid_input.py | 2 +- src/tests/test_entry_add.py | 8 +- src/tests/test_manager_retrieve_ssh_seed.py | 75 +++++++++++ 7 files changed, 280 insertions(+), 22 deletions(-) create mode 100644 src/tests/test_manager_retrieve_ssh_seed.py diff --git a/README.md b/README.md index 09b2610..e06649a 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - **SeedPass 2FA:** Generate TOTP codes with a real-time countdown progress bar. - **2FA Secret Issuance & Import:** Derive new TOTP secrets from your seed or import existing `otpauth://` URIs. - **Export 2FA Codes:** Save all stored TOTP entries to an encrypted JSON file for use with other apps. +- **SSH Key & Seed Derivation:** Generate deterministic SSH keys and new BIP-39 seed phrases from your master seed. - **Optional External Backup Location:** Configure a second directory where backups are automatically copied. - **Auto‑Lock on Inactivity:** Vault locks after a configurable timeout for additional security. - **Secret Mode:** Copy retrieved passwords directly to your clipboard and automatically clear it after a delay. @@ -205,7 +206,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 **BIP-39 Seed**. ### Adding a 2FA Entry diff --git a/src/main.py b/src/main.py index f8bf76c..74a4433 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. BIP-39 Seed") + 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 098ede5..5cbcfef 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -209,27 +209,68 @@ class EntryManager: logger.error(f"Failed to generate otpauth URI: {e}") raise - def add_ssh_key(self, notes: str = "") -> int: - """Placeholder for adding an SSH key entry.""" - index = self.get_next_index() + def get_next_ssh_index(self) -> int: + """Return the next available derivation index for SSH keys.""" data = self.vault.load_index() - data.setdefault("entries", {}) - data["entries"][str(index)] = {"type": EntryType.SSH.value, "notes": notes} - self._save_index(data) - self.update_checksum() - self.backup_manager.create_backup() - raise NotImplementedError("SSH key entry support not implemented yet") + entries = data.get("entries", {}) + indices = [ + int(v.get("index", 0)) + for v in entries.values() + if v.get("type") == EntryType.SSH.value + ] + return (max(indices) + 1) if indices else 0 - def add_seed(self, notes: str = "") -> int: - """Placeholder for adding a seed entry.""" - index = self.get_next_index() + def add_ssh_key(self, *, index: int | None = None, notes: str = "") -> int: + """Add an SSH key entry and return the entry id.""" + entry_id = self.get_next_index() data = self.vault.load_index() data.setdefault("entries", {}) - data["entries"][str(index)] = {"type": EntryType.SEED.value, "notes": notes} + if index is None: + index = self.get_next_ssh_index() + data["entries"][str(entry_id)] = { + "type": EntryType.SSH.value, + "index": index, + "notes": notes, + } self._save_index(data) self.update_checksum() self.backup_manager.create_backup() - raise NotImplementedError("Seed entry support not implemented yet") + return entry_id + + def get_next_seed_index(self) -> int: + """Return the next available derivation index for seed phrases.""" + data = self.vault.load_index() + entries = data.get("entries", {}) + indices = [ + int(v.get("index", 0)) + for v in entries.values() + if v.get("type") == EntryType.SEED.value + ] + return (max(indices) + 1) if indices else 0 + + def add_seed( + self, + *, + index: int | None = None, + words: int = 24, + notes: str = "", + ) -> int: + """Add a BIP-39 seed phrase entry and return the entry id.""" + entry_id = self.get_next_index() + data = self.vault.load_index() + data.setdefault("entries", {}) + if index is None: + index = self.get_next_seed_index() + data["entries"][str(entry_id)] = { + "type": EntryType.SEED.value, + "index": index, + "words": words, + "notes": notes, + } + self._save_index(data) + self.update_checksum() + self.backup_manager.create_backup() + return entry_id def get_totp_code( self, index: int, parent_seed: str | None = None, timestamp: int | None = None diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index c20e0aa..0e3c1bf 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -22,7 +22,11 @@ from termcolor import colored from password_manager.encryption import EncryptionManager from password_manager.entry_management import EntryManager -from password_manager.password_generation import PasswordGenerator +from password_manager.password_generation import ( + PasswordGenerator, + derive_ssh_key, + derive_seed_phrase, +) from password_manager.backup import BackupManager from password_manager.vault import Vault from password_manager.portable_backup import export_backup, import_backup @@ -1021,6 +1025,83 @@ 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 a new SSH key entry derived from the seed.""" + try: + notes = input("Enter notes (optional): ").strip() + ssh_index = self.entry_manager.get_next_ssh_index() + entry_id = self.entry_manager.add_ssh_key(index=ssh_index, notes=notes) + self.is_dirty = True + self.last_update = time.time() + key_hex = derive_ssh_key(self.password_generator.bip85, ssh_index).hex() + print(colored(f"\n[+] SSH key entry added with ID {entry_id}.", "green")) + print(colored(f"Derivation index: {ssh_index}", "cyan")) + if self.secret_mode_enabled: + copy_to_clipboard(key_hex, self.clipboard_clear_delay) + print( + colored( + f"SSH key copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(colored(f"SSH key (hex): {key_hex}", "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 SSH key generation: {e}", exc_info=True) + print(colored(f"Error: Failed to add SSH key: {e}", "red")) + + def handle_add_seed(self) -> None: + """Add a new BIP-39 seed phrase entry derived from the seed.""" + try: + words_input = input("Number of words (12 or 24, default 24): ").strip() + notes = input("Enter notes (optional): ").strip() + words = 24 + if words_input: + if words_input not in {"12", "24"}: + print(colored("Invalid word count. Use 12 or 24.", "red")) + return + words = int(words_input) + seed_index = self.entry_manager.get_next_seed_index() + entry_id = self.entry_manager.add_seed( + index=seed_index, words=words, notes=notes + ) + self.is_dirty = True + self.last_update = time.time() + phrase = derive_seed_phrase( + self.password_generator.bip85, seed_index, words + ) + print( + colored(f"\n[+] Seed phrase entry added with ID {entry_id}.", "green") + ) + print(colored(f"Derivation index: {seed_index}", "cyan")) + if self.secret_mode_enabled: + copy_to_clipboard(phrase, self.clipboard_clear_delay) + print( + colored( + f"Seed phrase copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(colored(f"Seed phrase: {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 generation: {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 @@ -1094,6 +1175,57 @@ class PasswordManager: print(colored(f"Error: Failed to generate TOTP code: {e}", "red")) return + elif entry_type == EntryType.SSH.value: + ssh_index = int(entry.get("index", 0)) + notes = entry.get("notes", "") + ssh_key = derive_ssh_key(self.password_generator.bip85, ssh_index).hex() + print( + colored( + f"Retrieving SSH key derived from index {ssh_index}.", "cyan" + ) + ) + if self.secret_mode_enabled: + copy_to_clipboard(ssh_key, self.clipboard_clear_delay) + print( + colored( + f"[+] SSH key copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(colored("\n[+] Retrieved SSH Key:\n", "green")) + print(colored(ssh_key, "yellow")) + if notes: + print(colored(f"Notes: {notes}", "cyan")) + return + elif entry_type == EntryType.SEED.value: + seed_index = int(entry.get("index", 0)) + words = int(entry.get("words", 24)) + notes = entry.get("notes", "") + phrase = derive_seed_phrase( + self.password_generator.bip85, seed_index, words + ) + print( + colored( + f"Retrieving {words}-word seed derived from index {seed_index}.", + "cyan", + ) + ) + if self.secret_mode_enabled: + copy_to_clipboard(phrase, self.clipboard_clear_delay) + print( + colored( + f"[+] Seed phrase copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(colored("\n[+] Retrieved Seed Phrase:\n", "green")) + print(colored(phrase, "yellow")) + if notes: + print(colored(f"Notes: {notes}", "cyan")) + return + website_name = entry.get("website") length = entry.get("length") username = entry.get("username") 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 b1f625d..72e91e2 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -60,10 +60,10 @@ def test_round_trip_entry_types(method, expected_type): elif method == "add_totp": entry_mgr.add_totp("example", TEST_SEED) index = 0 - else: - with pytest.raises(NotImplementedError): - getattr(entry_mgr, method)() - index = 0 + elif method == "add_ssh_key": + index = entry_mgr.add_ssh_key() + elif method == "add_seed": + index = entry_mgr.add_seed() entry = entry_mgr.retrieve_entry(index) assert entry["type"] == expected_type diff --git a/src/tests/test_manager_retrieve_ssh_seed.py b/src/tests/test_manager_retrieve_ssh_seed.py new file mode 100644 index 0000000..a968b73 --- /dev/null +++ b/src/tests/test_manager_retrieve_ssh_seed.py @@ -0,0 +1,75 @@ +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 FakeNostrClient: + def __init__(self, *args, **kwargs): + self.published = [] + + def publish_snapshot(self, data: bytes): + self.published.append(data) + return None, "abcd" + + +def _setup(tmp_path: Path) -> PasswordManager: + 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 = FakeNostrClient() + pm.fingerprint_dir = tmp_path + pm.is_dirty = False + pm.secret_mode_enabled = False + pm.password_generator = SimpleNamespace(bip85=object()) + return pm + + +def test_retrieve_ssh(monkeypatch, capsys): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + pm = _setup(tmp_path) + idx = pm.entry_manager.add_ssh_key() + monkeypatch.setattr( + "password_manager.manager.derive_ssh_key", + lambda b, i: bytes.fromhex("11" * 32), + ) + monkeypatch.setattr("builtins.input", lambda *a, **k: str(idx)) + pm.handle_retrieve_entry() + out = capsys.readouterr().out + assert "SSH key" in out + assert "11" * 32 in out + + +def test_retrieve_seed(monkeypatch, capsys): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + pm = _setup(tmp_path) + idx = pm.entry_manager.add_seed() + monkeypatch.setattr( + "password_manager.manager.derive_seed_phrase", + lambda b, i, w: "word " * w, + ) + monkeypatch.setattr("builtins.input", lambda *a, **k: str(idx)) + pm.handle_retrieve_entry() + out = capsys.readouterr().out + assert "Seed Phrase" in out or "seed" in out.lower() + assert "word" in out