diff --git a/src/main.py b/src/main.py index 2e3a493..0b56a64 100644 --- a/src/main.py +++ b/src/main.py @@ -731,8 +731,9 @@ def display_menu( print("2. 2FA (TOTP)") print("3. SSH Key") print("4. Seed Phrase") - print("5. PGP Key") - print("6. Back") + print("5. Nostr Key Pair") + print("6. PGP Key") + print("7. Back") sub_choice = input("Select entry type: ").strip() password_manager.update_activity() if sub_choice == "1": @@ -748,9 +749,12 @@ def display_menu( password_manager.handle_add_seed() break elif sub_choice == "5": - password_manager.handle_add_pgp() + password_manager.handle_add_nostr_key() break elif sub_choice == "6": + password_manager.handle_add_pgp() + break + elif sub_choice == "7": break else: print(colored("Invalid choice.", "red")) diff --git a/src/nostr/key_manager.py b/src/nostr/key_manager.py index 2436914..ed6ca88 100644 --- a/src/nostr/key_manager.py +++ b/src/nostr/key_manager.py @@ -128,3 +128,14 @@ class KeyManager: except Exception as e: logger.error(f"Failed to generate npub: {e}", exc_info=True) raise + + def get_nsec(self) -> str: + """Return the nsec (Bech32 encoded private key).""" + try: + priv_hex = self.get_private_key_hex() + priv_bytes = bytes.fromhex(priv_hex) + data = convertbits(priv_bytes, 8, 5, True) + return bech32_encode("nsec", data) + except Exception as e: + logger.error(f"Failed to generate nsec: {e}", exc_info=True) + raise diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index d655b1d..48a6081 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -310,6 +310,56 @@ class EntryManager: user_id = entry.get("user_id", "") return derive_pgp_key(bip85, key_idx, key_type, user_id) + def add_nostr_key( + self, + label: str, + index: int | None = None, + notes: str = "", + ) -> int: + """Add a new Nostr key pair entry.""" + + if index is None: + index = self.get_next_index() + + data = self.vault.load_index() + data.setdefault("entries", {}) + data["entries"][str(index)] = { + "type": EntryType.NOSTR.value, + "kind": EntryType.NOSTR.value, + "index": index, + "label": label, + "notes": notes, + } + 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.""" + + entry = self.retrieve_entry(index) + etype = entry.get("type") if entry else None + kind = entry.get("kind") if entry else None + if not entry or ( + etype != EntryType.NOSTR.value and kind != EntryType.NOSTR.value + ): + raise ValueError("Entry is not a Nostr key entry") + + from local_bip85.bip85 import BIP85 + from bip_utils import Bip39SeedGenerator + from nostr.coincurve_keys import Keys + + seed_bytes = Bip39SeedGenerator(parent_seed).Generate() + bip85 = BIP85(seed_bytes) + + key_idx = int(entry.get("index", index)) + entropy = bip85.derive_entropy(index=key_idx, bytes_len=32) + keys = Keys(priv_k=entropy.hex()) + npub = Keys.hex_to_bech32(keys.public_key_hex(), "npub") + nsec = Keys.hex_to_bech32(keys.private_key_hex(), "nsec") + return npub, nsec + def add_seed( self, parent_seed: str, diff --git a/src/password_manager/entry_types.py b/src/password_manager/entry_types.py index 186180b..5108925 100644 --- a/src/password_manager/entry_types.py +++ b/src/password_manager/entry_types.py @@ -12,3 +12,4 @@ class EntryType(str, Enum): SSH = "ssh" SEED = "seed" PGP = "pgp" + NOSTR = "nostr" diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 6cd8de8..e214698 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1123,6 +1123,38 @@ class PasswordManager: logging.error(f"Error during PGP key setup: {e}", exc_info=True) print(colored(f"Error: Failed to add PGP key: {e}", "red")) + def handle_add_nostr_key(self) -> None: + """Add a Nostr key entry and display the derived keys.""" + try: + label = input("Label (optional): ").strip() + notes = input("Notes (optional): ").strip() + index = self.entry_manager.add_nostr_key(label, notes=notes) + npub, nsec = self.entry_manager.get_nostr_key_pair(index, self.parent_seed) + self.is_dirty = True + self.last_update = time.time() + print(colored(f"\n[+] Nostr key entry added with ID {index}.\n", "green")) + print(colored(f"npub: {npub}", "cyan")) + if self.secret_mode_enabled: + copy_to_clipboard(nsec, self.clipboard_clear_delay) + print( + colored( + f"[+] nsec copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(colored(f"nsec: {nsec}", "cyan")) + 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, + ) + except Exception as e: + logging.error(f"Error during Nostr key setup: {e}", exc_info=True) + print(colored(f"Error: Failed to add Nostr key: {e}", "red")) + def handle_retrieve_entry(self) -> None: """ Handles retrieving a password from the index by prompting the user for the index number @@ -1283,6 +1315,32 @@ class PasswordManager: logging.error(f"Error deriving PGP key: {e}", exc_info=True) print(colored(f"Error: Failed to derive PGP key: {e}", "red")) return + if entry_type == EntryType.NOSTR.value: + label = entry.get("label", "") + notes = entry.get("notes", "") + try: + npub, nsec = self.entry_manager.get_nostr_key_pair( + index, self.parent_seed + ) + print(colored("\n[+] Retrieved Nostr Keys:\n", "green")) + print(colored(f"Label: {label}", "cyan")) + print(colored(f"npub: {npub}", "cyan")) + if self.secret_mode_enabled: + copy_to_clipboard(nsec, self.clipboard_clear_delay) + print( + colored( + f"[+] nsec copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(colored(f"nsec: {nsec}", "cyan")) + if notes: + print(colored(f"Notes: {notes}", "cyan")) + 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")) + return website_name = entry.get("website") length = entry.get("length") diff --git a/src/tests/test_cli_invalid_input.py b/src/tests/test_cli_invalid_input.py index c3c1f20..f16a147 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", "7", "6", "7"]) + inputs = iter(["1", "8", "7", "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_nostr_entry.py b/src/tests/test_nostr_entry.py new file mode 100644 index 0000000..5e8102b --- /dev/null +++ b/src/tests/test_nostr_entry.py @@ -0,0 +1,38 @@ +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.vault import Vault +from password_manager.config_manager import ConfigManager + + +def test_nostr_key_determinism(): + 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) + + idx = entry_mgr.add_nostr_key("main") + entry = entry_mgr.retrieve_entry(idx) + assert entry == { + "type": "nostr", + "kind": "nostr", + "index": idx, + "label": "main", + "notes": "", + } + + npub1, nsec1 = entry_mgr.get_nostr_key_pair(idx, TEST_SEED) + npub2, nsec2 = entry_mgr.get_nostr_key_pair(idx, TEST_SEED) + assert npub1 == npub2 + assert nsec1 == nsec2 + assert npub1.startswith("npub") + assert nsec1.startswith("nsec")