mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-09 15:58:48 +00:00
Merge pull request #238 from PR0M3TH3AN/codex/add-ui-elements-for-ssh-and-bip-39-seed
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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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):
|
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
|
### Adding a 2FA Entry
|
||||||
|
|
||||||
|
10
src/main.py
10
src/main.py
@@ -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. BIP-39 Seed")
|
||||||
|
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"))
|
||||||
|
@@ -209,27 +209,68 @@ class EntryManager:
|
|||||||
logger.error(f"Failed to generate otpauth URI: {e}")
|
logger.error(f"Failed to generate otpauth URI: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def add_ssh_key(self, notes: str = "") -> int:
|
def get_next_ssh_index(self) -> int:
|
||||||
"""Placeholder for adding an SSH key entry."""
|
"""Return the next available derivation index for SSH keys."""
|
||||||
index = self.get_next_index()
|
|
||||||
data = self.vault.load_index()
|
data = self.vault.load_index()
|
||||||
data.setdefault("entries", {})
|
entries = data.get("entries", {})
|
||||||
data["entries"][str(index)] = {"type": EntryType.SSH.value, "notes": notes}
|
indices = [
|
||||||
self._save_index(data)
|
int(v.get("index", 0))
|
||||||
self.update_checksum()
|
for v in entries.values()
|
||||||
self.backup_manager.create_backup()
|
if v.get("type") == EntryType.SSH.value
|
||||||
raise NotImplementedError("SSH key entry support not implemented yet")
|
]
|
||||||
|
return (max(indices) + 1) if indices else 0
|
||||||
|
|
||||||
def add_seed(self, notes: str = "") -> int:
|
def add_ssh_key(self, *, index: int | None = None, notes: str = "") -> int:
|
||||||
"""Placeholder for adding a seed entry."""
|
"""Add an SSH key entry and return the entry id."""
|
||||||
index = self.get_next_index()
|
entry_id = self.get_next_index()
|
||||||
data = self.vault.load_index()
|
data = self.vault.load_index()
|
||||||
data.setdefault("entries", {})
|
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._save_index(data)
|
||||||
self.update_checksum()
|
self.update_checksum()
|
||||||
self.backup_manager.create_backup()
|
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(
|
def get_totp_code(
|
||||||
self, index: int, parent_seed: str | None = None, timestamp: int | None = None
|
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.encryption import EncryptionManager
|
||||||
from password_manager.entry_management import EntryManager
|
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.backup import BackupManager
|
||||||
from password_manager.vault import Vault
|
from password_manager.vault import Vault
|
||||||
from password_manager.portable_backup import export_backup, import_backup
|
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)
|
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 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:
|
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
|
||||||
@@ -1094,6 +1175,57 @@ class PasswordManager:
|
|||||||
print(colored(f"Error: Failed to generate TOTP code: {e}", "red"))
|
print(colored(f"Error: Failed to generate TOTP code: {e}", "red"))
|
||||||
return
|
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")
|
website_name = entry.get("website")
|
||||||
length = entry.get("length")
|
length = entry.get("length")
|
||||||
username = entry.get("username")
|
username = entry.get("username")
|
||||||
|
@@ -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):
|
||||||
|
@@ -60,10 +60,10 @@ def test_round_trip_entry_types(method, expected_type):
|
|||||||
elif method == "add_totp":
|
elif method == "add_totp":
|
||||||
entry_mgr.add_totp("example", TEST_SEED)
|
entry_mgr.add_totp("example", TEST_SEED)
|
||||||
index = 0
|
index = 0
|
||||||
else:
|
elif method == "add_ssh_key":
|
||||||
with pytest.raises(NotImplementedError):
|
index = entry_mgr.add_ssh_key()
|
||||||
getattr(entry_mgr, method)()
|
elif method == "add_seed":
|
||||||
index = 0
|
index = entry_mgr.add_seed()
|
||||||
|
|
||||||
entry = entry_mgr.retrieve_entry(index)
|
entry = entry_mgr.retrieve_entry(index)
|
||||||
assert entry["type"] == expected_type
|
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