mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-09 15:58:48 +00:00
feat: add ssh and seed entry support
This commit is contained in:
@@ -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
|
||||
|
||||
|
10
src/main.py
10
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"))
|
||||
|
@@ -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
|
||||
|
@@ -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")
|
||||
|
@@ -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):
|
||||
|
@@ -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
|
||||
|
75
src/tests/test_manager_retrieve_ssh_seed.py
Normal file
75
src/tests/test_manager_retrieve_ssh_seed.py
Normal file
@@ -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
|
Reference in New Issue
Block a user