Document integration steps and add SSH/seed UI

This commit is contained in:
thePR0M3TH3AN
2025-07-04 17:25:02 -04:00
parent 92a4822355
commit b176486ca7
11 changed files with 161 additions and 18 deletions

View File

@@ -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. - Review code for potential information leaks (e.g., verbose logging) before submitting.
Following these practices helps keep the code base consistent and secure. 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 BIP39 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.

View File

@@ -205,7 +205,8 @@ python src/main.py
Enter your choice (1-7): 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 ### Adding a 2FA Entry

View File

@@ -729,7 +729,9 @@ def display_menu(
print("\nAdd Entry:") print("\nAdd Entry:")
print("1. Password") print("1. Password")
print("2. 2FA (TOTP)") 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() sub_choice = input("Select entry type: ").strip()
password_manager.update_activity() password_manager.update_activity()
if sub_choice == "1": if sub_choice == "1":
@@ -739,6 +741,12 @@ def display_menu(
password_manager.handle_add_totp() password_manager.handle_add_totp()
break break
elif sub_choice == "3": 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 break
else: else:
print(colored("Invalid choice.", "red")) print(colored("Invalid choice.", "red"))

View File

@@ -58,9 +58,13 @@ class EntryManager:
if self.index_file.exists(): if self.index_file.exists():
try: try:
data = self.vault.load_index() 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(): 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.") logger.debug("Index loaded successfully.")
return data return data
except Exception as e: except Exception as e:
@@ -132,6 +136,7 @@ class EntryManager:
"url": url if url else "", "url": url if url else "",
"blacklisted": blacklisted, "blacklisted": blacklisted,
"type": EntryType.PASSWORD.value, "type": EntryType.PASSWORD.value,
"kind": EntryType.PASSWORD.value,
"notes": notes, "notes": notes,
} }
@@ -158,7 +163,10 @@ class EntryManager:
indices = [ indices = [
int(v.get("index", 0)) int(v.get("index", 0))
for v in entries.values() 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 return (max(indices) + 1) if indices else 0
@@ -183,6 +191,7 @@ class EntryManager:
secret = TotpManager.derive_secret(parent_seed, index) secret = TotpManager.derive_secret(parent_seed, index)
entry = { entry = {
"type": EntryType.TOTP.value, "type": EntryType.TOTP.value,
"kind": EntryType.TOTP.value,
"label": label, "label": label,
"index": index, "index": index,
"period": period, "period": period,
@@ -191,6 +200,7 @@ class EntryManager:
else: else:
entry = { entry = {
"type": EntryType.TOTP.value, "type": EntryType.TOTP.value,
"kind": EntryType.TOTP.value,
"label": label, "label": label,
"secret": secret, "secret": secret,
"period": period, "period": period,
@@ -226,6 +236,7 @@ class EntryManager:
data.setdefault("entries", {}) data.setdefault("entries", {})
data["entries"][str(index)] = { data["entries"][str(index)] = {
"type": EntryType.SSH.value, "type": EntryType.SSH.value,
"kind": EntryType.SSH.value,
"index": index, "index": index,
"notes": notes, "notes": notes,
} }
@@ -238,7 +249,9 @@ class EntryManager:
"""Return the PEM formatted SSH key pair for the given entry.""" """Return the PEM formatted SSH key pair for the given entry."""
entry = self.retrieve_entry(index) 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") raise ValueError("Entry is not an SSH key entry")
from password_manager.password_generation import derive_ssh_key_pair from password_manager.password_generation import derive_ssh_key_pair
@@ -262,6 +275,7 @@ class EntryManager:
data.setdefault("entries", {}) data.setdefault("entries", {})
data["entries"][str(index)] = { data["entries"][str(index)] = {
"type": EntryType.SEED.value, "type": EntryType.SEED.value,
"kind": EntryType.SEED.value,
"index": index, "index": index,
"words": words_num, "words": words_num,
"notes": notes, "notes": notes,
@@ -275,7 +289,11 @@ class EntryManager:
"""Return the mnemonic seed phrase for the given entry.""" """Return the mnemonic seed phrase for the given entry."""
entry = self.retrieve_entry(index) 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") raise ValueError("Entry is not a seed entry")
from password_manager.password_generation import derive_seed_phrase from password_manager.password_generation import derive_seed_phrase
@@ -294,7 +312,11 @@ class EntryManager:
) -> str: ) -> str:
"""Return the current TOTP code for the specified entry.""" """Return the current TOTP code for the specified entry."""
entry = self.retrieve_entry(index) 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") raise ValueError("Entry is not a TOTP entry")
if "secret" in entry: if "secret" in entry:
return TotpManager.current_code_from_secret(entry["secret"], timestamp) return TotpManager.current_code_from_secret(entry["secret"], timestamp)
@@ -306,7 +328,11 @@ class EntryManager:
def get_totp_time_remaining(self, index: int) -> int: def get_totp_time_remaining(self, index: int) -> int:
"""Return seconds remaining in the TOTP period for the given entry.""" """Return seconds remaining in the TOTP period for the given entry."""
entry = self.retrieve_entry(index) 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") raise ValueError("Entry is not a TOTP entry")
period = int(entry.get("period", 30)) period = int(entry.get("period", 30))
@@ -395,7 +421,7 @@ class EntryManager:
) )
return 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 entry_type == EntryType.TOTP.value:
if label is not None: if label is not None:
@@ -472,14 +498,15 @@ class EntryManager:
for idx_str, entry in sorted_items: for idx_str, entry in sorted_items:
if ( if (
filter_kind is not None 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 continue
filtered_items.append((int(idx_str), entry)) filtered_items.append((int(idx_str), entry))
entries: List[Tuple[int, str, Optional[str], Optional[str], bool]] = [] entries: List[Tuple[int, str, Optional[str], Optional[str], bool]] = []
for idx, entry in filtered_items: 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: if etype == EntryType.TOTP.value:
entries.append((idx, entry.get("label", ""), None, None, False)) entries.append((idx, entry.get("label", ""), None, None, False))
else: else:
@@ -495,7 +522,7 @@ class EntryManager:
logger.debug(f"Total entries found: {len(entries)}") logger.debug(f"Total entries found: {len(entries)}")
for idx, entry in filtered_items: 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")) print(colored(f"Index: {idx}", "cyan"))
if etype == EntryType.TOTP.value: if etype == EntryType.TOTP.value:
print(colored(" Type: TOTP", "cyan")) print(colored(" Type: TOTP", "cyan"))
@@ -542,7 +569,7 @@ class EntryManager:
results: List[Tuple[int, str, Optional[str], Optional[str], bool]] = [] results: List[Tuple[int, str, Optional[str], Optional[str], bool]] = []
for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])): 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: if etype == EntryType.TOTP.value:
label = entry.get("label", "") label = entry.get("label", "")
notes = entry.get("notes", "") notes = entry.get("notes", "")

View File

@@ -1021,6 +1021,61 @@ class PasswordManager:
logging.error(f"Error during TOTP setup: {e}", exc_info=True) logging.error(f"Error during TOTP setup: {e}", exc_info=True)
print(colored(f"Error: Failed to add TOTP: {e}", "red")) 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: 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

View File

@@ -24,7 +24,13 @@ def test_backup_restore_workflow(monkeypatch):
data1 = { data1 = {
"schema_version": 2, "schema_version": 2,
"entries": { "entries": {
"0": {"website": "a", "length": 10, "type": "password", "notes": ""} "0": {
"website": "a",
"length": 10,
"type": "password",
"kind": "password",
"notes": "",
}
}, },
} }
vault.save_index(data1) vault.save_index(data1)
@@ -39,7 +45,13 @@ def test_backup_restore_workflow(monkeypatch):
data2 = { data2 = {
"schema_version": 2, "schema_version": 2,
"entries": { "entries": {
"0": {"website": "b", "length": 12, "type": "password", "notes": ""} "0": {
"website": "b",
"length": 12,
"type": "password",
"kind": "password",
"notes": "",
}
}, },
} }
vault.save_index(data2) vault.save_index(data2)

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", "4", "3", "7"]) inputs = iter(["1", "6", "5", "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

@@ -31,6 +31,7 @@ def test_add_and_retrieve_entry():
"url": "", "url": "",
"blacklisted": False, "blacklisted": False,
"type": "password", "type": "password",
"kind": "password",
"notes": "", "notes": "",
} }
@@ -70,8 +71,10 @@ def test_round_trip_entry_types(method, expected_type):
entry = entry_mgr.retrieve_entry(index) entry = entry_mgr.retrieve_entry(index)
assert entry["type"] == expected_type assert entry["type"] == expected_type
assert entry["kind"] == expected_type
data = enc_mgr.load_json_data(entry_mgr.index_file) data = enc_mgr.load_json_data(entry_mgr.index_file)
assert data["entries"][str(index)]["type"] == expected_type assert data["entries"][str(index)]["type"] == expected_type
assert data["entries"][str(index)]["kind"] == expected_type
def test_legacy_entry_defaults_to_password(): def test_legacy_entry_defaults_to_password():

View File

@@ -58,6 +58,7 @@ def test_handle_add_totp(monkeypatch, capsys):
entry = entry_mgr.retrieve_entry(0) entry = entry_mgr.retrieve_entry(0)
assert entry == { assert entry == {
"type": "totp", "type": "totp",
"kind": "totp",
"label": "Example", "label": "Example",
"index": 0, "index": 0,
"period": 30, "period": 30,

View File

@@ -22,7 +22,7 @@ def test_add_and_retrieve_ssh_key_pair():
index = entry_mgr.add_ssh_key(TEST_SEED) index = entry_mgr.add_ssh_key(TEST_SEED)
entry = entry_mgr.retrieve_entry(index) 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) priv1, pub1 = entry_mgr.get_ssh_key_pair(index, TEST_SEED)
priv2, pub2 = entry_mgr.get_ssh_key_pair(index, TEST_SEED) priv2, pub2 = entry_mgr.get_ssh_key_pair(index, TEST_SEED)

View File

@@ -30,6 +30,7 @@ def test_add_totp_and_get_code():
entry = entry_mgr.retrieve_entry(0) entry = entry_mgr.retrieve_entry(0)
assert entry == { assert entry == {
"type": "totp", "type": "totp",
"kind": "totp",
"label": "Example", "label": "Example",
"index": 0, "index": 0,
"period": 30, "period": 30,
@@ -66,6 +67,7 @@ def test_add_totp_imported(tmp_path):
entry = em.retrieve_entry(0) entry = em.retrieve_entry(0)
assert entry == { assert entry == {
"type": "totp", "type": "totp",
"kind": "totp",
"label": "Imported", "label": "Imported",
"secret": secret, "secret": secret,
"period": 30, "period": 30,