diff --git a/README.md b/README.md index be49332..587381a 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,20 @@ variable to control the delay between publishes when experimenting with large va pytest -vv -s -n 0 src/tests/test_nostr_index_size.py --desktop --max-entries=1000 ``` +### Generating a Test Profile + +Use the helper script below to populate a profile with sample entries for testing: + +```bash +python scripts/generate_test_profile.py --profile demo_profile --count 100 +``` + +The script now determines the fingerprint from the generated seed and stores the +vault under `~/.seedpass/`. It also prints the fingerprint after +creation and publishes the encrypted index to Nostr. Use that same seed phrase +to load SeedPass. The app checks Nostr on startup and pulls any newer snapshot +so your vault stays in sync across machines. + ### Automatically Updating the Script Checksum SeedPass stores a SHA-256 checksum for the main program in `~/.seedpass/seedpass_script_checksum.txt`. diff --git a/landing/SeedPass_Chart.mmd b/landing/SeedPass_Chart.mmd index 9fc3e04..e3aa511 100644 --- a/landing/SeedPass_Chart.mmd +++ b/landing/SeedPass_Chart.mmd @@ -1,7 +1,12 @@ --- config: layout: fixed - theme: neo-dark + theme: base + themeVariables: + primaryColor: '#e94a39' + primaryBorderColor: '#e94a39' + primaryTextColor: '#ffffff' + lineColor: '#e94a39' look: classic --- flowchart TD @@ -11,7 +16,7 @@ flowchart TD end subgraph subGraph1["Backup Pipeline"] direction TB - BK1["Incremental Backups
(BackupManager)"] + BK1["Timestamped Backups
(BackupManager)"] BK2["Portable Backup
(portable_backup.py)
.json.enc"] BK3["Nostr Snapshot
(nostr.client)
gzip chunks"] end diff --git a/landing/index.html b/landing/index.html index ae38bb9..90c4649 100644 --- a/landing/index.html +++ b/landing/index.html @@ -49,9 +49,28 @@
-

SeedPass: Secure Password Manager

-

SeedPass is a secure password generator and manager built on Bitcoin's BIP-85 standard. It uses deterministic key derivation to generate passwords that are never stored but can be easily regenerated when needed.

-

By integrating with the Nostr network, SeedPass compresses your encrypted vault and publishes it in 50 KB chunks. Each chunk is sent as a parameterised replaceable event, with deltas tracking changes between snapshots and automatic rotation when deltas grow large.

+

One Seed to Rule Them All

+

SeedPass deterministically derives every key and password from a single 12‑word phrase.

+
+---
+config:
+  theme: base
+  themeVariables:
+    primaryColor: '#e94a39'
+    primaryBorderColor: '#e94a39'
+    lineColor: '#e94a39'
+  look: classic
+---
+flowchart TB
+    seed["alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima"]
+    seed --> pw["πŸ”‘ Passwords"]
+    seed --> totp["πŸ“± 2FA Codes"]
+    seed --> ssh["πŸ–§ SSH Keys"]
+    seed --> pgp["πŸ”’ PGP Key"]
+    seed --> mn["🌱 Seed Phrase"]
+    seed --> nostr["⚑ Nostr Keys"]
+    classDef default fill:#ffffff,stroke:#e94a39,stroke-width:2px,color:#283c4f;
+                    
Get Started
@@ -63,7 +82,12 @@ --- config: layout: fixed - theme: neo-dark + theme: base + themeVariables: + primaryColor: '#e94a39' + primaryBorderColor: '#e94a39' + primaryTextColor: '#ffffff' + lineColor: '#e94a39' look: classic --- flowchart TD @@ -73,7 +97,7 @@ flowchart TD end subgraph subGraph1["Backup Pipeline"] direction TB - BK1["Incremental Backups
(BackupManager)"] + BK1["Timestamped Backups
(BackupManager)"] BK2["Portable Backup
(portable_backup.py)
.json.enc"] BK3["Nostr Snapshot
(nostr.client)
gzip chunks"] end diff --git a/landing/style.css b/landing/style.css index 5675cc9..7061f5b 100644 --- a/landing/style.css +++ b/landing/style.css @@ -337,6 +337,13 @@ footer .social-media a:focus { transform: translateY(-3px); } +/* Mini flow chart in hero */ +.mini-chart { + max-width: 600px; + margin: 40px auto; + background-color: transparent; +} + /* Features Section */ .features { background-color: var(--background-section); diff --git a/scripts/generate_test_profile.py b/scripts/generate_test_profile.py new file mode 100644 index 0000000..3b92f8b --- /dev/null +++ b/scripts/generate_test_profile.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Generate a SeedPass test profile with realistic entries. + +This script populates a profile directory with a variety of entry types. +If the profile does not exist, a new BIP-39 seed phrase is generated and +stored encrypted. A clear text copy is written to ``seed_phrase.txt`` so +it can be reused across devices. +""" + +from __future__ import annotations + +import argparse +import random +import sys +from pathlib import Path + +from bip_utils import Bip39Languages, Bip39MnemonicGenerator, Bip39WordsNum + +# Ensure src directory is in sys.path for imports +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SRC_DIR = PROJECT_ROOT / "src" +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +import constants as consts + +# Use a dedicated subdirectory for test profiles so regular data is not polluted +consts.APP_DIR = consts.APP_DIR / "tests" +consts.PARENT_SEED_FILE = consts.APP_DIR / "parent_seed.enc" +consts.SCRIPT_CHECKSUM_FILE = consts.APP_DIR / "seedpass_script_checksum.txt" + +from constants import APP_DIR, initialize_app +from utils.key_derivation import derive_key_from_password, derive_index_key +from password_manager.encryption import EncryptionManager +from password_manager.vault import Vault +from password_manager.config_manager import ConfigManager +from password_manager.backup import BackupManager +from password_manager.entry_management import EntryManager +from nostr.client import NostrClient +from utils.fingerprint import generate_fingerprint +from utils.fingerprint_manager import FingerprintManager +import bcrypt +import asyncio +import gzip + +DEFAULT_PASSWORD = "testpassword" + + +def initialize_profile(profile_name: str) -> tuple[str, EntryManager, Path, str]: + """Create or load a profile and return the seed phrase, manager, directory and fingerprint.""" + initialize_app() + seed_txt = APP_DIR / f"{profile_name}_seed.txt" + if seed_txt.exists(): + seed_phrase = seed_txt.read_text().strip() + else: + seed_phrase = ( + Bip39MnemonicGenerator(Bip39Languages.ENGLISH) + .FromWordsNumber(Bip39WordsNum.WORDS_NUM_12) + .ToStr() + ) + seed_txt.write_text(seed_phrase) + seed_txt.chmod(0o600) + + fp_mgr = FingerprintManager(APP_DIR) + fingerprint = fp_mgr.add_fingerprint(seed_phrase) or generate_fingerprint( + seed_phrase + ) + if fingerprint is None: + fingerprint = profile_name + profile_dir = APP_DIR / fingerprint + profile_dir.mkdir(parents=True, exist_ok=True) + + seed_key = derive_key_from_password(DEFAULT_PASSWORD) + seed_mgr = EncryptionManager(seed_key, profile_dir) + seed_file = profile_dir / "parent_seed.enc" + clear_path = profile_dir / "seed_phrase.txt" + + if seed_file.exists(): + try: + current = seed_mgr.decrypt_parent_seed() + except Exception: + current = None + if current != seed_phrase: + seed_mgr.encrypt_parent_seed(seed_phrase) + else: + seed_mgr.encrypt_parent_seed(seed_phrase) + + if not clear_path.exists(): + clear_path.write_text(seed_phrase) + clear_path.chmod(0o600) + + index_key = derive_index_key(seed_phrase) + enc_mgr = EncryptionManager(index_key, profile_dir) + vault = Vault(enc_mgr, profile_dir) + cfg_mgr = ConfigManager(vault, profile_dir) + # Store the default password hash so the profile can be opened + hashed = bcrypt.hashpw(DEFAULT_PASSWORD.encode(), bcrypt.gensalt()).decode() + cfg_mgr.set_password_hash(hashed) + backup_mgr = BackupManager(profile_dir, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + return seed_phrase, entry_mgr, profile_dir, fingerprint + + +def random_secret(length: int = 16) -> str: + alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + return "".join(random.choice(alphabet) for _ in range(length)) + + +def populate(entry_mgr: EntryManager, seed: str, count: int) -> None: + """Add ``count`` entries of varying types to the vault.""" + start_index = entry_mgr.get_next_index() + for i in range(count): + idx = start_index + i + kind = idx % 7 + if kind == 0: + entry_mgr.add_entry( + label=f"site-{idx}.example.com", + length=12, + username=f"user{idx}", + url=f"https://example{idx}.com", + notes=f"Website account {idx}", + custom_fields=[{"key": "id", "value": str(idx)}], + ) + elif kind == 1: + entry_mgr.add_totp(f"totp-generated-{idx}", seed) + elif kind == 2: + entry_mgr.add_totp(f"totp-imported-{idx}", seed, secret=random_secret()) + elif kind == 3: + entry_mgr.add_ssh_key(f"ssh-{idx}", seed, notes=f"SSH key for server {idx}") + elif kind == 4: + entry_mgr.add_seed( + f"derived-seed-{idx}", seed, words_num=24, notes=f"Seed {idx}" + ) + elif kind == 5: + entry_mgr.add_nostr_key(f"nostr-{idx}", notes=f"Nostr key {idx}") + else: + entry_mgr.add_pgp_key( + f"pgp-{idx}", + seed, + user_id=f"user{idx}@example.com", + notes=f"PGP key {idx}", + ) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Create or extend a SeedPass test profile" + ) + parser.add_argument( + "--profile", + default="test_profile", + help="profile name inside ~/.seedpass/tests", + ) + parser.add_argument( + "--count", + type=int, + default=100, + help="number of entries to add", + ) + args = parser.parse_args() + + seed, entry_mgr, dir_path, fingerprint = initialize_profile(args.profile) + print(f"Using profile directory: {dir_path}") + print(f"Parent seed: {seed}") + if fingerprint: + print(f"Fingerprint: {fingerprint}") + populate(entry_mgr, seed, args.count) + print(f"Added {args.count} entries.") + + encrypted = entry_mgr.vault.get_encrypted_index() + if encrypted: + client = NostrClient( + entry_mgr.vault.encryption_manager, + fingerprint or dir_path.name, + parent_seed=seed, + ) + asyncio.run(client.publish_snapshot(encrypted)) + print("[+] Data synchronized to Nostr.") + try: + result = asyncio.run(client.fetch_latest_snapshot()) + if result: + _, chunks = result + retrieved = gzip.decompress(b"".join(chunks)) + if retrieved == encrypted: + print("[+] Verified snapshot retrieval.") + else: + print( + f"[!] Retrieval failed: {client.last_error or 'data mismatch'}" + ) + else: + print(f"[!] Retrieval failed: {client.last_error or 'data mismatch'}") + except Exception as e: + print(f"[!] Retrieval failed: {e}") + else: + print("[-] No encrypted index found to sync.") + + +if __name__ == "__main__": + main() diff --git a/src/main.py b/src/main.py index 617550b..35f1298 100644 --- a/src/main.py +++ b/src/main.py @@ -304,6 +304,8 @@ def handle_post_to_nostr( except Exception as e: logging.error(f"Failed to post to Nostr: {e}", exc_info=True) print(colored(f"Error: Failed to post to Nostr: {e}", "red")) + finally: + pause() def handle_retrieve_from_nostr(password_manager: PasswordManager): @@ -336,6 +338,8 @@ def handle_retrieve_from_nostr(password_manager: PasswordManager): except Exception as e: logging.error(f"Failed to retrieve from Nostr: {e}", exc_info=True) print(colored(f"Error: Failed to retrieve from Nostr: {e}", "red")) + finally: + pause() def handle_view_relays(cfg_mgr: "ConfigManager") -> None: @@ -395,6 +399,8 @@ def handle_add_relay(password_manager: PasswordManager) -> None: except Exception as e: logging.error(f"Error adding relay: {e}") print(colored(f"Error: {e}", "red")) + finally: + pause() def handle_remove_relay(password_manager: PasswordManager) -> None: @@ -430,6 +436,8 @@ def handle_remove_relay(password_manager: PasswordManager) -> None: except Exception as e: logging.error(f"Error removing relay: {e}") print(colored(f"Error: {e}", "red")) + finally: + pause() def handle_reset_relays(password_manager: PasswordManager) -> None: @@ -447,6 +455,8 @@ def handle_reset_relays(password_manager: PasswordManager) -> None: except Exception as e: logging.error(f"Error resetting relays: {e}") print(colored(f"Error: {e}", "red")) + finally: + pause() def handle_set_inactivity_timeout(password_manager: PasswordManager) -> None: diff --git a/src/nostr/client.py b/src/nostr/client.py index 2ff7019..320ba72 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -153,10 +153,8 @@ class NostrClient: while True: msg = await asyncio.wait_for(ws.recv(), timeout=timeout) data = json.loads(msg) - if data[0] == "EVENT": + if data[0] in {"EVENT", "EOSE"}: return True - if data[0] == "EOSE": - return False except Exception: return False diff --git a/src/password_manager/encryption.py b/src/password_manager/encryption.py index f4457c1..5f6fe1c 100644 --- a/src/password_manager/encryption.py +++ b/src/password_manager/encryption.py @@ -376,14 +376,10 @@ class EncryptionManager: try: relative_path = Path("seedpass_entries_db.json.enc") if not (self.fingerprint_dir / relative_path).exists(): - logger.error( + # Missing index is normal on first run + logger.info( f"Index file '{relative_path}' does not exist in '{self.fingerprint_dir}'." ) - print( - colored( - f"Error: Index file '{relative_path}' does not exist.", "red" - ) - ) return None file_path = self.fingerprint_dir / relative_path diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 536cde7..cb76a02 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -302,7 +302,7 @@ class PasswordManager: # Initialize BIP85 and other managers self.initialize_bip85() self.initialize_managers() - self.sync_index_from_nostr_if_missing() + self.sync_index_from_nostr() print( colored( f"Seed profile {fingerprint} selected and managers initialized.", @@ -432,7 +432,7 @@ class PasswordManager: # Initialize BIP85 and other managers self.initialize_bip85() self.initialize_managers() - self.sync_index_from_nostr_if_missing() + self.sync_index_from_nostr() print(colored(f"Switched to seed profile {selected_fingerprint}.", "green")) # Re-initialize NostrClient with the new fingerprint @@ -616,7 +616,7 @@ class PasswordManager: self.initialize_bip85() self.initialize_managers() - self.sync_index_from_nostr_if_missing() + self.sync_index_from_nostr() return fingerprint # Return the generated or added fingerprint else: logging.error("Invalid BIP-85 seed phrase. Exiting.") @@ -757,7 +757,7 @@ class PasswordManager: self.initialize_bip85() self.initialize_managers() - self.sync_index_from_nostr_if_missing() + self.sync_index_from_nostr() except Exception as e: logging.error(f"Failed to encrypt and save parent seed: {e}", exc_info=True) print(colored(f"Error: Failed to encrypt and save parent seed: {e}", "red")) diff --git a/src/tests/test_generate_test_profile.py b/src/tests/test_generate_test_profile.py new file mode 100644 index 0000000..6313968 --- /dev/null +++ b/src/tests/test_generate_test_profile.py @@ -0,0 +1,32 @@ +import importlib +from pathlib import Path +from tempfile import TemporaryDirectory +import importlib.util + + +def test_initialize_profile_creates_directories(monkeypatch): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + # Mock home directory so APP_DIR is within tmp_path + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + # Reload constants to use the mocked home directory + constants = importlib.import_module("constants") + importlib.reload(constants) + # Load the script module directly from its path + script_path = ( + Path(__file__).resolve().parents[2] / "scripts" / "generate_test_profile.py" + ) + spec = importlib.util.spec_from_file_location( + "generate_test_profile", script_path + ) + gtp = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(gtp) + + seed, mgr, dir_path, fingerprint = gtp.initialize_profile("test") + + assert constants.APP_DIR.exists() + assert (constants.APP_DIR / "test_seed.txt").exists() + assert dir_path.exists() + assert dir_path.name == fingerprint diff --git a/src/tests/test_nostr_client.py b/src/tests/test_nostr_client.py index 8a849b8..310ab4b 100644 --- a/src/tests/test_nostr_client.py +++ b/src/tests/test_nostr_client.py @@ -2,12 +2,15 @@ import sys from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import patch +import json +import asyncio from cryptography.fernet import Fernet sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.encryption import EncryptionManager from nostr.client import NostrClient +import nostr.client as nostr_client def test_nostr_client_uses_custom_relays(): @@ -50,6 +53,25 @@ class FakeAddRelayClient: self.connected = True +class FakeWebSocket: + def __init__(self, messages): + self.messages = messages + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + pass + + async def send(self, _): + pass + + async def recv(self): + if self.messages: + return self.messages.pop(0) + await asyncio.sleep(0) + + def _setup_client(tmpdir, fake_cls): key = Fernet.generate_key() enc_mgr = EncryptionManager(key, Path(tmpdir)) @@ -91,3 +113,18 @@ def test_check_relay_health_runs_async(tmp_path, monkeypatch): assert result == 1 assert recorded["args"] == (3, 2) + + +def test_ping_relay_accepts_eose(tmp_path, monkeypatch): + client = _setup_client(tmp_path, FakeAddRelayClient) + + fake_ws = FakeWebSocket([json.dumps(["EOSE"])]) + + def fake_connect(*_args, **_kwargs): + return fake_ws + + monkeypatch.setattr(nostr_client.websockets, "connect", fake_connect) + + result = asyncio.run(client._ping_relay("wss://relay", timeout=0.1)) + + assert result is True