From f739ad8c8a57b8954530baab4f13fc5f2bd953cb Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 6 Jul 2025 15:10:21 -0400 Subject: [PATCH 01/18] Update architecture chart colors and text --- landing/SeedPass_Chart.mmd | 9 +++++++-- landing/index.html | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) 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..bd90bab 100644 --- a/landing/index.html +++ b/landing/index.html @@ -63,7 +63,12 @@ --- config: layout: fixed - theme: neo-dark + theme: base + themeVariables: + primaryColor: '#e94a39' + primaryBorderColor: '#e94a39' + primaryTextColor: '#ffffff' + lineColor: '#e94a39' look: classic --- flowchart TD @@ -73,7 +78,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 From 437a2aa5aaa3b435d39dfe0c0e1d3abf61594d7e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 6 Jul 2025 15:31:14 -0400 Subject: [PATCH 02/18] Add script to generate sample SeedPass profile --- scripts/generate_test_profile.py | 126 +++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 scripts/generate_test_profile.py diff --git a/scripts/generate_test_profile.py b/scripts/generate_test_profile.py new file mode 100644 index 0000000..ea5d7ea --- /dev/null +++ b/scripts/generate_test_profile.py @@ -0,0 +1,126 @@ +#!/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 +from pathlib import Path + +from bip_utils import Bip39MnemonicGenerator, Bip39WordsNum, Bip39Languages + +from constants import APP_DIR +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 + +DEFAULT_PASSWORD = "testpassword" + + +def initialize_profile(profile_name: str) -> tuple[str, EntryManager, Path]: + """Create or load a profile and return the seed phrase and manager.""" + profile_dir = APP_DIR / profile_name + 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" + + if seed_file.exists(): + seed_phrase = seed_mgr.decrypt_parent_seed() + else: + seed_phrase = ( + Bip39MnemonicGenerator(Bip39Languages.ENGLISH) + .FromWordsNumber(Bip39WordsNum.WORDS_NUM_12) + .ToStr() + ) + seed_mgr.encrypt_parent_seed(seed_phrase) + clear_path = profile_dir / "seed_phrase.txt" + 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) + backup_mgr = BackupManager(profile_dir, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + return seed_phrase, entry_mgr, profile_dir + + +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", + ) + parser.add_argument( + "--count", + type=int, + default=100, + help="number of entries to add", + ) + args = parser.parse_args() + + seed, entry_mgr, dir_path = initialize_profile(args.profile) + print(f"Using profile directory: {dir_path}") + print(f"Parent seed: {seed}") + populate(entry_mgr, seed, args.count) + print(f"Added {args.count} entries.") + + +if __name__ == "__main__": + main() From f2b351fb1260b7187ec5082cea3d98ce20889804 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 6 Jul 2025 15:38:01 -0400 Subject: [PATCH 03/18] docs: add test profile instructions --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index be49332..00ec7c1 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,16 @@ 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 my_profile --count 100 +``` + +This command creates `~/.seedpass/my_profile` if needed and adds 100 example entries. + ### Automatically Updating the Script Checksum SeedPass stores a SHA-256 checksum for the main program in `~/.seedpass/seedpass_script_checksum.txt`. From cc1b5f12a15f659a900a37c1df42e04dc8f5bb29 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 6 Jul 2025 15:43:58 -0400 Subject: [PATCH 04/18] Update hero section with seed diagram --- landing/index.html | 25 ++++++++++++++++++++++--- landing/style.css | 7 +++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/landing/index.html b/landing/index.html index bd90bab..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
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); From 7c1a929e1cce8413b39bc4f69a07959d5065b2a5 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 6 Jul 2025 15:45:44 -0400 Subject: [PATCH 05/18] fix import path in test profile generator --- scripts/generate_test_profile.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/generate_test_profile.py b/scripts/generate_test_profile.py index ea5d7ea..de09b0a 100644 --- a/scripts/generate_test_profile.py +++ b/scripts/generate_test_profile.py @@ -11,9 +11,16 @@ from __future__ import annotations import argparse import random +import sys from pathlib import Path -from bip_utils import Bip39MnemonicGenerator, Bip39WordsNum, Bip39Languages +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)) from constants import APP_DIR from utils.key_derivation import derive_key_from_password, derive_index_key From d262f0278f0e5d3def251bea1e8101880717e702 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 6 Jul 2025 16:15:58 -0400 Subject: [PATCH 06/18] Add pauses to Nostr settings actions --- src/main.py | 10 ++++++++++ 1 file changed, 10 insertions(+) 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: From 1e8e41889002de426c8eb6ef7c06a5d42ea6624c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 6 Jul 2025 16:23:01 -0400 Subject: [PATCH 07/18] Sync test profile data to nostr --- README.md | 2 ++ scripts/generate_test_profile.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/README.md b/README.md index 00ec7c1..30cfe1e 100644 --- a/README.md +++ b/README.md @@ -364,6 +364,8 @@ python scripts/generate_test_profile.py --profile my_profile --count 100 ``` This command creates `~/.seedpass/my_profile` if needed and adds 100 example entries. +After populating the vault, the script now automatically publishes the encrypted +index to Nostr so the data can be retrieved on other devices. ### Automatically Updating the Script Checksum diff --git a/scripts/generate_test_profile.py b/scripts/generate_test_profile.py index de09b0a..3ab575f 100644 --- a/scripts/generate_test_profile.py +++ b/scripts/generate_test_profile.py @@ -29,6 +29,8 @@ 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 +import asyncio DEFAULT_PASSWORD = "testpassword" @@ -128,6 +130,18 @@ def main() -> None: 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, + dir_path.name, + parent_seed=seed, + ) + asyncio.run(client.publish_snapshot(encrypted)) + print("[+] Data synchronized to Nostr.") + else: + print("[-] No encrypted index found to sync.") + if __name__ == "__main__": main() From 27cd82ba647af6190b8c5ecddc460f1f233c15b5 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 6 Jul 2025 16:34:00 -0400 Subject: [PATCH 08/18] Fix test profile sync fingerprint --- README.md | 6 ++++-- scripts/generate_test_profile.py | 6 +++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 30cfe1e..6e0e9b6 100644 --- a/README.md +++ b/README.md @@ -364,8 +364,10 @@ python scripts/generate_test_profile.py --profile my_profile --count 100 ``` This command creates `~/.seedpass/my_profile` if needed and adds 100 example entries. -After populating the vault, the script now automatically publishes the encrypted -index to Nostr so the data can be retrieved on other devices. +After populating the vault, the script prints the derived **fingerprint** and +automatically publishes the encrypted index to Nostr under that fingerprint. +Use the same seed phrase when loading SeedPass so it can retrieve the data from +Nostr. ### Automatically Updating the Script Checksum diff --git a/scripts/generate_test_profile.py b/scripts/generate_test_profile.py index 3ab575f..1f5212b 100644 --- a/scripts/generate_test_profile.py +++ b/scripts/generate_test_profile.py @@ -30,6 +30,7 @@ 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 import asyncio DEFAULT_PASSWORD = "testpassword" @@ -125,8 +126,11 @@ def main() -> None: args = parser.parse_args() seed, entry_mgr, dir_path = initialize_profile(args.profile) + fingerprint = generate_fingerprint(seed) 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.") @@ -134,7 +138,7 @@ def main() -> None: if encrypted: client = NostrClient( entry_mgr.vault.encryption_manager, - dir_path.name, + fingerprint or dir_path.name, parent_seed=seed, ) asyncio.run(client.publish_snapshot(encrypted)) From 57e7cf30040958ca43082296183621165cf0a65f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 6 Jul 2025 16:56:54 -0400 Subject: [PATCH 09/18] Verify Nostr snapshot retrieval --- scripts/generate_test_profile.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/scripts/generate_test_profile.py b/scripts/generate_test_profile.py index 1f5212b..736bd62 100644 --- a/scripts/generate_test_profile.py +++ b/scripts/generate_test_profile.py @@ -32,6 +32,7 @@ from password_manager.entry_management import EntryManager from nostr.client import NostrClient from utils.fingerprint import generate_fingerprint import asyncio +import gzip DEFAULT_PASSWORD = "testpassword" @@ -143,6 +144,21 @@ def main() -> None: ) 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.") From af049a258a90e924a2439cdaae5dd08ce4447a02 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 6 Jul 2025 17:07:34 -0400 Subject: [PATCH 10/18] fix relay health check --- src/nostr/client.py | 4 +--- src/tests/test_nostr_client.py | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) 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/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 From 527fd95a593820111aa353a2b475b153195ef61e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 6 Jul 2025 17:16:03 -0400 Subject: [PATCH 11/18] Ensure seed file reused --- scripts/generate_test_profile.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/generate_test_profile.py b/scripts/generate_test_profile.py index 736bd62..801b1fc 100644 --- a/scripts/generate_test_profile.py +++ b/scripts/generate_test_profile.py @@ -45,9 +45,16 @@ def initialize_profile(profile_name: str) -> tuple[str, EntryManager, Path]: 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(): seed_phrase = seed_mgr.decrypt_parent_seed() + if not clear_path.exists(): + clear_path.write_text(seed_phrase) + clear_path.chmod(0o600) + elif clear_path.exists(): + seed_phrase = clear_path.read_text().strip() + seed_mgr.encrypt_parent_seed(seed_phrase) else: seed_phrase = ( Bip39MnemonicGenerator(Bip39Languages.ENGLISH) @@ -55,7 +62,6 @@ def initialize_profile(profile_name: str) -> tuple[str, EntryManager, Path]: .ToStr() ) seed_mgr.encrypt_parent_seed(seed_phrase) - clear_path = profile_dir / "seed_phrase.txt" clear_path.write_text(seed_phrase) clear_path.chmod(0o600) From f85968afa9dca613a0f40234ec7ff0e35aabe608 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 6 Jul 2025 17:46:42 -0400 Subject: [PATCH 12/18] Improve test profile script --- README.md | 11 +++++------ scripts/generate_test_profile.py | 33 ++++++++++++++++++++++---------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 6e0e9b6..8677c07 100644 --- a/README.md +++ b/README.md @@ -360,14 +360,13 @@ pytest -vv -s -n 0 src/tests/test_nostr_index_size.py --desktop --max-entries=10 Use the helper script below to populate a profile with sample entries for testing: ```bash -python scripts/generate_test_profile.py --profile my_profile --count 100 +python scripts/generate_test_profile.py --profile demo_profile --count 100 ``` -This command creates `~/.seedpass/my_profile` if needed and adds 100 example entries. -After populating the vault, the script prints the derived **fingerprint** and -automatically publishes the encrypted index to Nostr under that fingerprint. -Use the same seed phrase when loading SeedPass so it can retrieve the data from -Nostr. +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 so it can automatically retrieve the data from Nostr. ### Automatically Updating the Script Checksum diff --git a/scripts/generate_test_profile.py b/scripts/generate_test_profile.py index 801b1fc..20b309f 100644 --- a/scripts/generate_test_profile.py +++ b/scripts/generate_test_profile.py @@ -37,15 +37,15 @@ import gzip DEFAULT_PASSWORD = "testpassword" -def initialize_profile(profile_name: str) -> tuple[str, EntryManager, Path]: - """Create or load a profile and return the seed phrase and manager.""" - profile_dir = APP_DIR / profile_name - profile_dir.mkdir(parents=True, exist_ok=True) +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.""" + temp_dir = APP_DIR / profile_name + temp_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" + seed_mgr = EncryptionManager(seed_key, temp_dir) + seed_file = temp_dir / "parent_seed.enc" + clear_path = temp_dir / "seed_phrase.txt" if seed_file.exists(): seed_phrase = seed_mgr.decrypt_parent_seed() @@ -65,13 +65,27 @@ def initialize_profile(profile_name: str) -> tuple[str, EntryManager, Path]: clear_path.write_text(seed_phrase) clear_path.chmod(0o600) + fingerprint = generate_fingerprint(seed_phrase) or profile_name + profile_dir = APP_DIR / fingerprint + if profile_dir != temp_dir: + profile_dir.mkdir(parents=True, exist_ok=True) + for p in temp_dir.iterdir(): + target = profile_dir / p.name + if not target.exists(): + p.rename(target) + try: + temp_dir.rmdir() + except OSError: + pass + seed_mgr.fingerprint_dir = profile_dir + 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) backup_mgr = BackupManager(profile_dir, cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) - return seed_phrase, entry_mgr, profile_dir + return seed_phrase, entry_mgr, profile_dir, fingerprint def random_secret(length: int = 16) -> str: @@ -132,8 +146,7 @@ def main() -> None: ) args = parser.parse_args() - seed, entry_mgr, dir_path = initialize_profile(args.profile) - fingerprint = generate_fingerprint(seed) + seed, entry_mgr, dir_path, fingerprint = initialize_profile(args.profile) print(f"Using profile directory: {dir_path}") print(f"Parent seed: {seed}") if fingerprint: From a1b44105e491504d5904ed00edd48ed3b6f0a5b9 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 6 Jul 2025 17:56:42 -0400 Subject: [PATCH 13/18] Sync from Nostr on profile selection --- README.md | 3 ++- src/password_manager/manager.py | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8677c07..587381a 100644 --- a/README.md +++ b/README.md @@ -366,7 +366,8 @@ 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 so it can automatically retrieve the data from Nostr. +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 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")) From ccbaedefa03cc9bdb5ceccdada3202caa4154e38 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 6 Jul 2025 18:22:50 -0400 Subject: [PATCH 14/18] Improve test profile persistence --- scripts/generate_test_profile.py | 53 +++++++++++++++----------------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/scripts/generate_test_profile.py b/scripts/generate_test_profile.py index 20b309f..3dea26d 100644 --- a/scripts/generate_test_profile.py +++ b/scripts/generate_test_profile.py @@ -39,45 +39,40 @@ 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.""" - temp_dir = APP_DIR / profile_name - temp_dir.mkdir(parents=True, exist_ok=True) - - seed_key = derive_key_from_password(DEFAULT_PASSWORD) - seed_mgr = EncryptionManager(seed_key, temp_dir) - seed_file = temp_dir / "parent_seed.enc" - clear_path = temp_dir / "seed_phrase.txt" - - if seed_file.exists(): - seed_phrase = seed_mgr.decrypt_parent_seed() - if not clear_path.exists(): - clear_path.write_text(seed_phrase) - clear_path.chmod(0o600) - elif clear_path.exists(): - seed_phrase = clear_path.read_text().strip() - seed_mgr.encrypt_parent_seed(seed_phrase) + 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_mgr.encrypt_parent_seed(seed_phrase) - clear_path.write_text(seed_phrase) - clear_path.chmod(0o600) + seed_txt.write_text(seed_phrase) + seed_txt.chmod(0o600) fingerprint = generate_fingerprint(seed_phrase) or profile_name profile_dir = APP_DIR / fingerprint - if profile_dir != temp_dir: - profile_dir.mkdir(parents=True, exist_ok=True) - for p in temp_dir.iterdir(): - target = profile_dir / p.name - if not target.exists(): - p.rename(target) + 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: - temp_dir.rmdir() - except OSError: - pass - seed_mgr.fingerprint_dir = profile_dir + 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) From 174eb3bcca91a9d9b03601252cc040df355ca525 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 6 Jul 2025 18:32:19 -0400 Subject: [PATCH 15/18] Ensure generate_test_profile creates app dir --- scripts/generate_test_profile.py | 3 ++- src/tests/test_generate_test_profile.py | 32 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 src/tests/test_generate_test_profile.py diff --git a/scripts/generate_test_profile.py b/scripts/generate_test_profile.py index 3dea26d..3add6c2 100644 --- a/scripts/generate_test_profile.py +++ b/scripts/generate_test_profile.py @@ -22,7 +22,7 @@ SRC_DIR = PROJECT_ROOT / "src" if str(SRC_DIR) not in sys.path: sys.path.insert(0, str(SRC_DIR)) -from constants import APP_DIR +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 @@ -39,6 +39,7 @@ 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() 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 From 3b1d7943d0ac642ade7972a551a5e543a521e217 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 6 Jul 2025 18:45:20 -0400 Subject: [PATCH 16/18] Register generated test profiles --- scripts/generate_test_profile.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/generate_test_profile.py b/scripts/generate_test_profile.py index 3add6c2..9b70de4 100644 --- a/scripts/generate_test_profile.py +++ b/scripts/generate_test_profile.py @@ -31,6 +31,8 @@ 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 @@ -52,7 +54,12 @@ def initialize_profile(profile_name: str) -> tuple[str, EntryManager, Path, str] seed_txt.write_text(seed_phrase) seed_txt.chmod(0o600) - fingerprint = generate_fingerprint(seed_phrase) or profile_name + 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) @@ -79,6 +86,9 @@ def initialize_profile(profile_name: str) -> tuple[str, EntryManager, Path, str] 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 From c2f6971b86a38f4d745f94a9ccd79cfbcbda15e6 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 6 Jul 2025 18:57:18 -0400 Subject: [PATCH 17/18] Isolate test profiles --- scripts/generate_test_profile.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/scripts/generate_test_profile.py b/scripts/generate_test_profile.py index 3add6c2..3b92f8b 100644 --- a/scripts/generate_test_profile.py +++ b/scripts/generate_test_profile.py @@ -22,6 +22,13 @@ 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 @@ -31,6 +38,8 @@ 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 @@ -52,7 +61,12 @@ def initialize_profile(profile_name: str) -> tuple[str, EntryManager, Path, str] seed_txt.write_text(seed_phrase) seed_txt.chmod(0o600) - fingerprint = generate_fingerprint(seed_phrase) or profile_name + 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) @@ -79,6 +93,9 @@ def initialize_profile(profile_name: str) -> tuple[str, EntryManager, Path, str] 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 @@ -132,7 +149,7 @@ def main() -> None: parser.add_argument( "--profile", default="test_profile", - help="profile name inside ~/.seedpass", + help="profile name inside ~/.seedpass/tests", ) parser.add_argument( "--count", From a8f95c93610563c576d5267deeba5873cab05d0e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 6 Jul 2025 19:03:50 -0400 Subject: [PATCH 18/18] Relax missing index log --- src/password_manager/encryption.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) 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