Merge pull request #247 from PR0M3TH3AN/codex/add-nostr-key-pair-generation-and-features

Add Nostr key pair generation
This commit is contained in:
thePR0M3TH3AN
2025-07-04 19:19:05 -04:00
committed by GitHub
7 changed files with 166 additions and 4 deletions

View File

@@ -731,8 +731,9 @@ def display_menu(
print("2. 2FA (TOTP)") print("2. 2FA (TOTP)")
print("3. SSH Key") print("3. SSH Key")
print("4. Seed Phrase") print("4. Seed Phrase")
print("5. PGP Key") print("5. Nostr Key Pair")
print("6. Back") print("6. PGP Key")
print("7. Back")
sub_choice = input("Select entry type: ").strip() sub_choice = input("Select entry type: ").strip()
password_manager.update_activity() password_manager.update_activity()
if sub_choice == "1": if sub_choice == "1":
@@ -748,9 +749,12 @@ def display_menu(
password_manager.handle_add_seed() password_manager.handle_add_seed()
break break
elif sub_choice == "5": elif sub_choice == "5":
password_manager.handle_add_pgp() password_manager.handle_add_nostr_key()
break break
elif sub_choice == "6": elif sub_choice == "6":
password_manager.handle_add_pgp()
break
elif sub_choice == "7":
break break
else: else:
print(colored("Invalid choice.", "red")) print(colored("Invalid choice.", "red"))

View File

@@ -128,3 +128,14 @@ class KeyManager:
except Exception as e: except Exception as e:
logger.error(f"Failed to generate npub: {e}", exc_info=True) logger.error(f"Failed to generate npub: {e}", exc_info=True)
raise 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

View File

@@ -310,6 +310,56 @@ class EntryManager:
user_id = entry.get("user_id", "") user_id = entry.get("user_id", "")
return derive_pgp_key(bip85, key_idx, key_type, 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( def add_seed(
self, self,
parent_seed: str, parent_seed: str,

View File

@@ -12,3 +12,4 @@ class EntryType(str, Enum):
SSH = "ssh" SSH = "ssh"
SEED = "seed" SEED = "seed"
PGP = "pgp" PGP = "pgp"
NOSTR = "nostr"

View File

@@ -1123,6 +1123,38 @@ class PasswordManager:
logging.error(f"Error during PGP key setup: {e}", exc_info=True) logging.error(f"Error during PGP key setup: {e}", exc_info=True)
print(colored(f"Error: Failed to add PGP key: {e}", "red")) 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: def handle_retrieve_entry(self) -> None:
""" """
Handles retrieving a password from the index by prompting the user for the index number 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) logging.error(f"Error deriving PGP key: {e}", exc_info=True)
print(colored(f"Error: Failed to derive PGP key: {e}", "red")) print(colored(f"Error: Failed to derive PGP key: {e}", "red"))
return 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") website_name = entry.get("website")
length = entry.get("length") length = entry.get("length")

View File

@@ -77,7 +77,7 @@ def test_out_of_range_menu(monkeypatch, capsys):
def test_invalid_add_entry_submenu(monkeypatch, capsys): def test_invalid_add_entry_submenu(monkeypatch, capsys):
called = {"add": False, "retrieve": False, "modify": False} called = {"add": False, "retrieve": False, "modify": False}
pm, _ = _make_pm(called) 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(main, "timed_input", lambda *_: next(inputs))
monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
with pytest.raises(SystemExit): with pytest.raises(SystemExit):

View File

@@ -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")