From 12ab76badfbad41d9a9ae0616661f3f6f0c2627c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 09:36:58 -0400 Subject: [PATCH 01/43] Add encryption mode prompt helper --- src/password_manager/manager.py | 47 ++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index e0adcfe..e576ee6 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -115,6 +115,30 @@ class PasswordManager: """Record the current time as the last user activity.""" self.last_activity = time.time() + def prompt_encryption_mode(self) -> EncryptionMode: + """Prompt the user to select an encryption mode. + + Returns: + EncryptionMode: The chosen encryption mode. + """ + print("Choose encryption mode [Enter for seed-only]") + print(" 1) seed-only") + print(" 2) seed+password") + print(" 3) password-only (legacy)") + mode_choice = input("Select option: ").strip() + + if mode_choice == "2": + return EncryptionMode.SEED_PLUS_PW + elif mode_choice == "3": + print( + colored( + "⚠️ Password-only encryption is less secure and not recommended.", + "yellow", + ) + ) + return EncryptionMode.PW_ONLY + return EncryptionMode.SEED_ONLY + def lock_vault(self) -> None: """Clear sensitive information from memory.""" self.parent_seed = None @@ -197,9 +221,11 @@ class PasswordManager: def add_new_fingerprint(self): """ - Adds a new seed profile by generating it from a seed phrase. + Adds a new seed profile by prompting for encryption mode and generating + it from a seed phrase. """ try: + self.encryption_mode = self.prompt_encryption_mode() choice = input( "Do you want to (1) Enter an existing seed or (2) Generate a new seed? (1/2): " ).strip() @@ -480,24 +506,7 @@ class PasswordManager: """ print(colored("No existing seed found. Let's set up a new one!", "yellow")) - print("Choose encryption mode [Enter for seed-only]") - print(" 1) seed-only") - print(" 2) seed+password") - print(" 3) password-only (legacy)") - mode_choice = input("Select option: ").strip() - - if mode_choice == "2": - self.encryption_mode = EncryptionMode.SEED_PLUS_PW - elif mode_choice == "3": - self.encryption_mode = EncryptionMode.PW_ONLY - print( - colored( - "⚠️ Password-only encryption is less secure and not recommended.", - "yellow", - ) - ) - else: - self.encryption_mode = EncryptionMode.SEED_ONLY + self.encryption_mode = self.prompt_encryption_mode() choice = input( "Do you want to (1) Enter an existing BIP-85 seed or (2) Generate a new BIP-85 seed? (1/2): " From 4d9bcf6d3b011bcfad56b0f3619d809de9aa537d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 11:02:53 -0400 Subject: [PATCH 02/43] Add encryption mode change feature --- src/password_manager/config_manager.py | 12 +++++ src/password_manager/manager.py | 51 +++++++++++++++++++++ src/tests/test_encryption_mode_change.py | 56 ++++++++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 src/tests/test_encryption_mode_change.py diff --git a/src/password_manager/config_manager.py b/src/password_manager/config_manager.py index b64842b..ac4b46a 100644 --- a/src/password_manager/config_manager.py +++ b/src/password_manager/config_manager.py @@ -12,6 +12,10 @@ import bcrypt from password_manager.vault import Vault from nostr.client import DEFAULT_RELAYS as DEFAULT_NOSTR_RELAYS +from utils.key_derivation import ( + EncryptionMode, + DEFAULT_ENCRYPTION_MODE, +) logger = logging.getLogger(__name__) @@ -41,6 +45,7 @@ class ConfigManager: "relays": list(DEFAULT_NOSTR_RELAYS), "pin_hash": "", "password_hash": "", + "encryption_mode": DEFAULT_ENCRYPTION_MODE.value, } try: data = self.vault.load_config() @@ -50,6 +55,7 @@ class ConfigManager: data.setdefault("relays", list(DEFAULT_NOSTR_RELAYS)) data.setdefault("pin_hash", "") data.setdefault("password_hash", "") + data.setdefault("encryption_mode", DEFAULT_ENCRYPTION_MODE.value) # Migrate legacy hashed_password.enc if present and password_hash is missing legacy_file = self.fingerprint_dir / "hashed_password.enc" @@ -113,3 +119,9 @@ class ConfigManager: config = self.load_config(require_pin=False) config["password_hash"] = password_hash self.save_config(config) + + def set_encryption_mode(self, mode: EncryptionMode) -> None: + """Persist the selected encryption mode in the config.""" + config = self.load_config(require_pin=False) + config["encryption_mode"] = mode.value + self.save_config(config) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index e576ee6..d2886a7 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -723,6 +723,7 @@ class PasswordManager: ) self.store_hashed_password(password) + self.config_manager.set_encryption_mode(self.encryption_mode) logging.info("User password hashed and stored successfully.") seed_mgr.encrypt_parent_seed(seed) @@ -1459,3 +1460,53 @@ class PasswordManager: except Exception as e: logging.error(f"Failed to change password: {e}", exc_info=True) print(colored(f"Error: Failed to change password: {e}", "red")) + + def change_encryption_mode(self, new_mode: EncryptionMode) -> None: + """Re-encrypt the index using a different encryption mode.""" + try: + password = prompt_existing_password("Enter your current master password: ") + if not self.verify_password(password): + print(colored("Incorrect password.", "red")) + return + + index_data = self.vault.load_index() + config_data = self.config_manager.load_config(require_pin=False) + + new_key = derive_index_key(self.parent_seed, password, new_mode) + new_mgr = EncryptionManager(new_key, self.fingerprint_dir) + + self.vault.set_encryption_manager(new_mgr) + self.vault.save_index(index_data) + self.config_manager.vault = self.vault + config_data["encryption_mode"] = new_mode.value + self.config_manager.save_config(config_data) + + self.encryption_manager = new_mgr + self.password_generator.encryption_manager = new_mgr + self.encryption_mode = new_mode + + relay_list = config_data.get("relays", list(DEFAULT_RELAYS)) + self.nostr_client = NostrClient( + encryption_manager=self.encryption_manager, + fingerprint=self.current_fingerprint, + relays=relay_list, + parent_seed=getattr(self, "parent_seed", None), + ) + + print(colored("Encryption mode changed successfully.", "green")) + + try: + encrypted_data = self.get_encrypted_data() + if encrypted_data: + summary = f"mode-change-{int(time.time())}" + self.nostr_client.publish_json_to_nostr( + encrypted_data, + alt_summary=summary, + ) + except Exception as nostr_error: + logging.error( + f"Failed to post updated index to Nostr after encryption mode change: {nostr_error}" + ) + except Exception as e: + logging.error(f"Failed to change encryption mode: {e}", exc_info=True) + print(colored(f"Error: Failed to change encryption mode: {e}", "red")) diff --git a/src/tests/test_encryption_mode_change.py b/src/tests/test_encryption_mode_change.py new file mode 100644 index 0000000..b2c225e --- /dev/null +++ b/src/tests/test_encryption_mode_change.py @@ -0,0 +1,56 @@ +import sys +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace +from unittest.mock import patch + +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.config_manager import ConfigManager +from password_manager.vault import Vault +from password_manager.manager import PasswordManager +from utils.key_derivation import EncryptionMode + + +def test_change_encryption_mode(monkeypatch): + with TemporaryDirectory() as tmpdir: + fp = Path(tmpdir) + vault, enc_mgr = create_vault( + fp, TEST_SEED, TEST_PASSWORD, EncryptionMode.SEED_ONLY + ) + entry_mgr = EntryManager(vault, fp) + cfg_mgr = ConfigManager(vault, fp) + vault.save_index({"passwords": {}}) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_manager = enc_mgr + pm.entry_manager = entry_mgr + pm.config_manager = cfg_mgr + pm.vault = vault + pm.password_generator = SimpleNamespace(encryption_manager=enc_mgr) + pm.fingerprint_dir = fp + pm.current_fingerprint = "fp" + pm.parent_seed = TEST_SEED + pm.encryption_mode = EncryptionMode.SEED_ONLY + + monkeypatch.setattr( + "password_manager.manager.prompt_existing_password", + lambda *_: TEST_PASSWORD, + ) + pm.verify_password = lambda pw: True + + with patch("password_manager.manager.NostrClient") as MockClient: + mock = MockClient.return_value + pm.nostr_client = mock + pm.change_encryption_mode(EncryptionMode.SEED_PLUS_PW) + mock.publish_json_to_nostr.assert_called_once() + + assert pm.encryption_mode is EncryptionMode.SEED_PLUS_PW + assert pm.password_generator.encryption_manager is pm.encryption_manager + loaded = vault.load_index() + assert loaded["passwords"] == {} + cfg = cfg_mgr.load_config(require_pin=False) + assert cfg["encryption_mode"] == EncryptionMode.SEED_PLUS_PW.value From ba892b19191048808032a7fc3372bfe74e1876df Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 11:17:59 -0400 Subject: [PATCH 03/43] Add encryption mode change option in settings --- src/main.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/main.py b/src/main.py index 0c7a03b..e67dba8 100644 --- a/src/main.py +++ b/src/main.py @@ -456,12 +456,13 @@ def handle_settings(password_manager: PasswordManager) -> None: print("1. Profiles") print("2. Nostr") print("3. Change password") - print("4. Verify Script Checksum") - print("5. Backup Parent Seed") - print("6. Export database") - print("7. Import database") - print("8. Lock Vault") - print("9. Back") + print("4. Change encryption mode") + print("5. Verify Script Checksum") + print("6. Backup Parent Seed") + print("7. Export database") + print("8. Import database") + print("9. Lock Vault") + print("10. Back") choice = input("Select an option: ").strip() if choice == "1": handle_profiles_menu(password_manager) @@ -470,20 +471,27 @@ def handle_settings(password_manager: PasswordManager) -> None: elif choice == "3": password_manager.change_password() elif choice == "4": - password_manager.handle_verify_checksum() + try: + mode = password_manager.prompt_encryption_mode() + password_manager.change_encryption_mode(mode) + except Exception as exc: + logging.error(f"Error changing encryption mode: {exc}", exc_info=True) + print(colored(f"Error: Failed to change encryption mode: {exc}", "red")) elif choice == "5": - password_manager.handle_backup_reveal_parent_seed() + password_manager.handle_verify_checksum() elif choice == "6": - password_manager.handle_export_database() + password_manager.handle_backup_reveal_parent_seed() elif choice == "7": + password_manager.handle_export_database() + elif choice == "8": path = input("Enter path to backup file: ").strip() if path: password_manager.handle_import_database(Path(path)) - elif choice == "8": + elif choice == "9": password_manager.lock_vault() print(colored("Vault locked. Please re-enter your password.", "yellow")) password_manager.unlock_vault() - elif choice == "9": + elif choice == "10": break else: print(colored("Invalid choice.", "red")) From 24e6d70af4fc738c77fb40f3f47202a6b604c4d8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 11:51:14 -0400 Subject: [PATCH 04/43] Add encryption mode migration tests --- src/tests/test_encryption_mode_migration.py | 94 +++++++++++++++++++++ src/tests/test_password_properties.py | 3 +- 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 src/tests/test_encryption_mode_migration.py diff --git a/src/tests/test_encryption_mode_migration.py b/src/tests/test_encryption_mode_migration.py new file mode 100644 index 0000000..4427f89 --- /dev/null +++ b/src/tests/test_encryption_mode_migration.py @@ -0,0 +1,94 @@ +import sys +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace + +import bcrypt +import pytest + +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.config_manager import ConfigManager +from password_manager.vault import Vault +from password_manager.manager import PasswordManager +from utils.key_derivation import EncryptionMode + + +TRANSITIONS = [ + (EncryptionMode.SEED_ONLY, EncryptionMode.SEED_PLUS_PW), + (EncryptionMode.SEED_ONLY, EncryptionMode.PW_ONLY), + (EncryptionMode.SEED_PLUS_PW, EncryptionMode.SEED_ONLY), + (EncryptionMode.SEED_PLUS_PW, EncryptionMode.PW_ONLY), + (EncryptionMode.PW_ONLY, EncryptionMode.SEED_ONLY), + (EncryptionMode.PW_ONLY, EncryptionMode.SEED_PLUS_PW), +] + + +@pytest.mark.parametrize("start_mode,new_mode", TRANSITIONS) +def test_encryption_mode_migration(monkeypatch, start_mode, new_mode): + with TemporaryDirectory() as tmpdir: + fp = Path(tmpdir) + vault, enc_mgr = create_vault(fp, TEST_SEED, TEST_PASSWORD, start_mode) + entry_mgr = EntryManager(vault, fp) + cfg_mgr = ConfigManager(vault, fp) + + vault.save_index({"passwords": {}}) + cfg_mgr.save_config( + { + "relays": [], + "pin_hash": "", + "password_hash": bcrypt.hashpw( + TEST_PASSWORD.encode(), bcrypt.gensalt() + ).decode(), + "encryption_mode": start_mode.value, + } + ) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_manager = enc_mgr + pm.entry_manager = entry_mgr + pm.config_manager = cfg_mgr + pm.vault = vault + pm.password_generator = SimpleNamespace(encryption_manager=enc_mgr) + pm.fingerprint_dir = fp + pm.current_fingerprint = "fp" + pm.parent_seed = TEST_SEED + pm.encryption_mode = start_mode + pm.nostr_client = SimpleNamespace(publish_json_to_nostr=lambda *a, **k: None) + + monkeypatch.setattr( + "password_manager.manager.prompt_existing_password", + lambda *_: TEST_PASSWORD, + ) + monkeypatch.setattr( + "password_manager.manager.NostrClient", + lambda *a, **kw: SimpleNamespace( + publish_json_to_nostr=lambda *a, **k: None + ), + ) + + pm.change_encryption_mode(new_mode) + + assert pm.encryption_mode is new_mode + cfg = cfg_mgr.load_config(require_pin=False) + assert cfg["encryption_mode"] == new_mode.value + + pm.lock_vault() + + monkeypatch.setattr( + "password_manager.manager.prompt_existing_password", + lambda *_: TEST_PASSWORD, + ) + monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None) + monkeypatch.setattr(PasswordManager, "initialize_managers", lambda self: None) + + pm.unlock_vault() + + assert pm.parent_seed == TEST_SEED + assert not pm.locked + assert pm.encryption_mode is new_mode + assert pm.vault.load_index()["passwords"] == {} + assert pm.verify_password(TEST_PASSWORD) diff --git a/src/tests/test_password_properties.py b/src/tests/test_password_properties.py index 3a226b6..aa48621 100644 --- a/src/tests/test_password_properties.py +++ b/src/tests/test_password_properties.py @@ -1,7 +1,7 @@ import sys import string from pathlib import Path -from hypothesis import given, strategies as st +from hypothesis import given, strategies as st, settings sys.path.append(str(Path(__file__).resolve().parents[1])) @@ -29,6 +29,7 @@ def make_generator(): length=st.integers(min_value=8, max_value=64), index=st.integers(min_value=0, max_value=1000), ) +@settings(deadline=None) def test_password_properties(length, index): pg = make_generator() pw1 = pg.generate_password(length=length, index=index) From c6f4d185dabebb7e46656c95d64937bb288bdde5 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 12:04:16 -0400 Subject: [PATCH 05/43] Add manual Nostr index size test --- pytest.ini | 1 + src/tests/conftest.py | 13 ++++++ src/tests/test_nostr_index_size.py | 63 ++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 src/tests/test_nostr_index_size.py diff --git a/pytest.ini b/pytest.ini index 1aa25c7..25e1f5c 100644 --- a/pytest.ini +++ b/pytest.ini @@ -7,5 +7,6 @@ testpaths = src/tests markers = network: tests that require network connectivity stress: long running stress tests + desktop: desktop only tests filterwarnings = ignore::DeprecationWarning:multiprocessing.popen_fork diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 6daa678..80dbacd 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -14,10 +14,17 @@ def pytest_addoption(parser: pytest.Parser) -> None: default=False, help="run stress tests", ) + parser.addoption( + "--desktop", + action="store_true", + default=False, + help="run desktop-only tests", + ) def pytest_configure(config: pytest.Config) -> None: config.addinivalue_line("markers", "stress: long running stress tests") + config.addinivalue_line("markers", "desktop: desktop only tests") def pytest_collection_modifyitems( @@ -30,3 +37,9 @@ def pytest_collection_modifyitems( for item in items: if "stress" in item.keywords: item.add_marker(skip_stress) + + if not config.getoption("--desktop"): + skip_desktop = pytest.mark.skip(reason="need --desktop option to run") + for item in items: + if "desktop" in item.keywords: + item.add_marker(skip_desktop) diff --git a/src/tests/test_nostr_index_size.py b/src/tests/test_nostr_index_size.py new file mode 100644 index 0000000..02279fd --- /dev/null +++ b/src/tests/test_nostr_index_size.py @@ -0,0 +1,63 @@ +import time +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +import pytest +from cryptography.fernet import Fernet + +from password_manager.encryption import EncryptionManager +from password_manager.entry_management import EntryManager +from password_manager.vault import Vault +from nostr.client import NostrClient, Kind, KindStandard + + +@pytest.mark.desktop +@pytest.mark.network +def test_nostr_index_size_limits(): + """Manually explore maximum index size for Nostr backups.""" + seed = ( + "abandon abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon about" + ) + results = [] + with TemporaryDirectory() as tmpdir: + key = Fernet.generate_key() + enc_mgr = EncryptionManager(key, Path(tmpdir)) + with patch.object(enc_mgr, "decrypt_parent_seed", return_value=seed): + client = NostrClient( + enc_mgr, + "size_test_fp", + relays=["wss://relay.snort.social"], + ) + vault = Vault(enc_mgr, tmpdir) + entry_mgr = EntryManager(vault, Path(tmpdir)) + + sizes = [16, 64, 256, 1024, 2048, 4096, 8192] + for size in sizes: + try: + entry_mgr.add_entry( + website_name=f"site-{size}", + length=12, + username="u" * size, + url="https://example.com/" + "a" * size, + ) + encrypted = vault.get_encrypted_index() + payload_size = len(encrypted) if encrypted else 0 + published = client.publish_json_to_nostr(encrypted or b"") + time.sleep(2) + retrieved = client.retrieve_json_from_nostr_sync() + retrieved_ok = retrieved == encrypted + results.append((size, payload_size, published, retrieved_ok)) + if not published or not retrieved_ok: + break + except Exception: + results.append((size, None, False, False)) + break + client.close_client_pool() + + note_kind = Kind.from_std(KindStandard.TEXT_NOTE).to_int() + print(f"\nNostr note Kind: {note_kind}") + print("Size | Payload Bytes | Published | Retrieved") + for size, payload, pub, ret in results: + print(f"{size:>4} | {payload:>13} | {pub} | {ret}") From f591eb260b1984215f44ed9a1c4219c4963d014a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 12:12:16 -0400 Subject: [PATCH 06/43] docs: add instructions for nostr index size test --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 05754c6..c2413b0 100644 --- a/README.md +++ b/README.md @@ -231,11 +231,25 @@ Back in the Settings menu you can: SeedPass includes a small suite of unit tests located under `src/tests`. After activating your virtual environment and installing dependencies, run the tests with **pytest**. Use `-vv` to see INFO-level log messages from each passing test: + ```bash pip install -r src/requirements.txt pytest -vv ``` +### Exploring Nostr Index Size Limits + +The `test_nostr_index_size.py` test probes how large the encrypted index can +be when posted to Nostr. It requires network access and is tagged with +`desktop` and `network`, so run it manually when you want to measure payload +limits: + +```bash +pytest -vv src/tests/test_nostr_index_size.py +``` + +Add `-m "desktop and network"` if you normally exclude those markers. + ### Automatically Updating the Script Checksum SeedPass stores a SHA-256 checksum for the main program in `~/.seedpass/seedpass_script_checksum.txt`. From 36ff65e076677fde771da33ceb39c13ac2ac5b71 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 12:19:40 -0400 Subject: [PATCH 07/43] Fix missing sys.path update in nostr index size test --- src/tests/test_nostr_index_size.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/tests/test_nostr_index_size.py b/src/tests/test_nostr_index_size.py index 02279fd..f920ba9 100644 --- a/src/tests/test_nostr_index_size.py +++ b/src/tests/test_nostr_index_size.py @@ -2,10 +2,14 @@ import time from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import patch +import sys import pytest + from cryptography.fernet import Fernet +sys.path.append(str(Path(__file__).resolve().parents[1])) + from password_manager.encryption import EncryptionManager from password_manager.entry_management import EntryManager from password_manager.vault import Vault From 636a9bbec234a2f6919d52e95f29ab54870d821b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 12:31:16 -0400 Subject: [PATCH 08/43] Fix Nostr kind retrieval for size test --- src/tests/test_nostr_index_size.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/test_nostr_index_size.py b/src/tests/test_nostr_index_size.py index f920ba9..8af7487 100644 --- a/src/tests/test_nostr_index_size.py +++ b/src/tests/test_nostr_index_size.py @@ -60,7 +60,7 @@ def test_nostr_index_size_limits(): break client.close_client_pool() - note_kind = Kind.from_std(KindStandard.TEXT_NOTE).to_int() + note_kind = Kind.from_std(KindStandard.TEXT_NOTE).as_u16() print(f"\nNostr note Kind: {note_kind}") print("Size | Payload Bytes | Published | Retrieved") for size, payload, pub, ret in results: From 810e02254e4237c10081c2f88d398c80b54fee47 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 12:38:18 -0400 Subject: [PATCH 09/43] Throttle Nostr index size test --- src/tests/test_nostr_index_size.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/tests/test_nostr_index_size.py b/src/tests/test_nostr_index_size.py index 8af7487..872100c 100644 --- a/src/tests/test_nostr_index_size.py +++ b/src/tests/test_nostr_index_size.py @@ -1,3 +1,4 @@ +import os import time from pathlib import Path from tempfile import TemporaryDirectory @@ -18,6 +19,7 @@ from nostr.client import NostrClient, Kind, KindStandard @pytest.mark.desktop @pytest.mark.network +@pytest.mark.skipif(not os.getenv("NOSTR_E2E"), reason="NOSTR_E2E not set") def test_nostr_index_size_limits(): """Manually explore maximum index size for Nostr backups.""" seed = ( @@ -38,6 +40,7 @@ def test_nostr_index_size_limits(): entry_mgr = EntryManager(vault, Path(tmpdir)) sizes = [16, 64, 256, 1024, 2048, 4096, 8192] + delay = float(os.getenv("NOSTR_TEST_DELAY", "5")) for size in sizes: try: entry_mgr.add_entry( @@ -49,7 +52,7 @@ def test_nostr_index_size_limits(): encrypted = vault.get_encrypted_index() payload_size = len(encrypted) if encrypted else 0 published = client.publish_json_to_nostr(encrypted or b"") - time.sleep(2) + time.sleep(delay) retrieved = client.retrieve_json_from_nostr_sync() retrieved_ok = retrieved == encrypted results.append((size, payload_size, published, retrieved_ok)) From 3533c096b041c2bf851a99997f928b873eee411b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 12:51:50 -0400 Subject: [PATCH 10/43] Remove NOSTR_E2E guard and show npub --- src/tests/test_nostr_index_size.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tests/test_nostr_index_size.py b/src/tests/test_nostr_index_size.py index 872100c..0e54a06 100644 --- a/src/tests/test_nostr_index_size.py +++ b/src/tests/test_nostr_index_size.py @@ -19,7 +19,6 @@ from nostr.client import NostrClient, Kind, KindStandard @pytest.mark.desktop @pytest.mark.network -@pytest.mark.skipif(not os.getenv("NOSTR_E2E"), reason="NOSTR_E2E not set") def test_nostr_index_size_limits(): """Manually explore maximum index size for Nostr backups.""" seed = ( @@ -36,6 +35,7 @@ def test_nostr_index_size_limits(): "size_test_fp", relays=["wss://relay.snort.social"], ) + npub = client.key_manager.get_npub() vault = Vault(enc_mgr, tmpdir) entry_mgr = EntryManager(vault, Path(tmpdir)) @@ -65,6 +65,7 @@ def test_nostr_index_size_limits(): note_kind = Kind.from_std(KindStandard.TEXT_NOTE).as_u16() print(f"\nNostr note Kind: {note_kind}") + print(f"Nostr account npub: {npub}") print("Size | Payload Bytes | Published | Retrieved") for size, payload, pub, ret in results: print(f"{size:>4} | {payload:>13} | {pub} | {ret}") From 4ad65a883732c888b79ed5e2846372ba80f0ff89 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 14:05:27 -0400 Subject: [PATCH 11/43] feat: make inactivity timeout configurable --- src/main.py | 47 +++++++++++++++++++++++--- src/password_manager/config_manager.py | 16 +++++++++ src/password_manager/manager.py | 5 +++ src/tests/test_config_manager.py | 14 ++++++++ 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/src/main.py b/src/main.py index e67dba8..3ba849f 100644 --- a/src/main.py +++ b/src/main.py @@ -379,6 +379,40 @@ def handle_reset_relays(password_manager: PasswordManager) -> None: print(colored(f"Error: {e}", "red")) +def handle_set_inactivity_timeout(password_manager: PasswordManager) -> None: + """Change the inactivity timeout for the current seed profile.""" + cfg_mgr = password_manager.config_manager + if cfg_mgr is None: + print(colored("Configuration manager unavailable.", "red")) + return + try: + current = cfg_mgr.get_inactivity_timeout() / 60 + print(colored(f"Current timeout: {current:.1f} minutes", "cyan")) + except Exception as e: + logging.error(f"Error loading timeout: {e}") + print(colored(f"Error: {e}", "red")) + return + value = input("Enter new timeout in minutes: ").strip() + if not value: + print(colored("No timeout entered.", "yellow")) + return + try: + minutes = float(value) + if minutes <= 0: + print(colored("Timeout must be positive.", "red")) + return + except ValueError: + print(colored("Invalid number.", "red")) + return + try: + cfg_mgr.set_inactivity_timeout(minutes * 60) + password_manager.inactivity_timeout = minutes * 60 + print(colored("Inactivity timeout updated.", "green")) + except Exception as e: + logging.error(f"Error saving timeout: {e}") + print(colored(f"Error: {e}", "red")) + + def handle_profiles_menu(password_manager: PasswordManager) -> None: """Submenu for managing seed profiles.""" while True: @@ -461,8 +495,9 @@ def handle_settings(password_manager: PasswordManager) -> None: print("6. Backup Parent Seed") print("7. Export database") print("8. Import database") - print("9. Lock Vault") - print("10. Back") + print("9. Set inactivity timeout") + print("10. Lock Vault") + print("11. Back") choice = input("Select an option: ").strip() if choice == "1": handle_profiles_menu(password_manager) @@ -488,10 +523,12 @@ def handle_settings(password_manager: PasswordManager) -> None: if path: password_manager.handle_import_database(Path(path)) elif choice == "9": + handle_set_inactivity_timeout(password_manager) + elif choice == "10": password_manager.lock_vault() print(colored("Vault locked. Please re-enter your password.", "yellow")) password_manager.unlock_vault() - elif choice == "10": + elif choice == "11": break else: print(colored("Invalid choice.", "red")) @@ -651,7 +688,9 @@ if __name__ == "__main__": # Display the interactive menu to the user try: - display_menu(password_manager) + display_menu( + password_manager, inactivity_timeout=password_manager.inactivity_timeout + ) except KeyboardInterrupt: logger.info("Program terminated by user via KeyboardInterrupt.") print(colored("\nProgram terminated by user.", "yellow")) diff --git a/src/password_manager/config_manager.py b/src/password_manager/config_manager.py index ac4b46a..eb689fb 100644 --- a/src/password_manager/config_manager.py +++ b/src/password_manager/config_manager.py @@ -16,6 +16,7 @@ from utils.key_derivation import ( EncryptionMode, DEFAULT_ENCRYPTION_MODE, ) +from constants import INACTIVITY_TIMEOUT logger = logging.getLogger(__name__) @@ -46,6 +47,7 @@ class ConfigManager: "pin_hash": "", "password_hash": "", "encryption_mode": DEFAULT_ENCRYPTION_MODE.value, + "inactivity_timeout": INACTIVITY_TIMEOUT, } try: data = self.vault.load_config() @@ -56,6 +58,7 @@ class ConfigManager: data.setdefault("pin_hash", "") data.setdefault("password_hash", "") data.setdefault("encryption_mode", DEFAULT_ENCRYPTION_MODE.value) + data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT) # Migrate legacy hashed_password.enc if present and password_hash is missing legacy_file = self.fingerprint_dir / "hashed_password.enc" @@ -125,3 +128,16 @@ class ConfigManager: config = self.load_config(require_pin=False) config["encryption_mode"] = mode.value self.save_config(config) + + def set_inactivity_timeout(self, timeout_seconds: float) -> None: + """Persist the inactivity timeout in seconds.""" + if timeout_seconds <= 0: + raise ValueError("Timeout must be positive") + config = self.load_config(require_pin=False) + config["inactivity_timeout"] = timeout_seconds + self.save_config(config) + + def get_inactivity_timeout(self) -> float: + """Retrieve the inactivity timeout setting in seconds.""" + config = self.load_config(require_pin=False) + return float(config.get("inactivity_timeout", INACTIVITY_TIMEOUT)) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index d2886a7..7c7ffb6 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -50,6 +50,7 @@ from constants import ( MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH, DEFAULT_PASSWORD_LENGTH, + INACTIVITY_TIMEOUT, DEFAULT_SEED_BACKUP_FILENAME, ) @@ -101,6 +102,7 @@ class PasswordManager: self.last_update: float = time.time() self.last_activity: float = time.time() self.locked: bool = False + self.inactivity_timeout: float = INACTIVITY_TIMEOUT # Initialize the fingerprint manager first self.initialize_fingerprint_manager() @@ -786,6 +788,9 @@ class PasswordManager: ) config = self.config_manager.load_config() relay_list = config.get("relays", list(DEFAULT_RELAYS)) + self.inactivity_timeout = config.get( + "inactivity_timeout", INACTIVITY_TIMEOUT + ) self.nostr_client = NostrClient( encryption_manager=self.encryption_manager, diff --git a/src/tests/test_config_manager.py b/src/tests/test_config_manager.py index 7433dba..92f3f31 100644 --- a/src/tests/test_config_manager.py +++ b/src/tests/test_config_manager.py @@ -10,6 +10,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.config_manager import ConfigManager from password_manager.vault import Vault from nostr.client import DEFAULT_RELAYS +from constants import INACTIVITY_TIMEOUT def test_config_defaults_and_round_trip(): @@ -80,6 +81,19 @@ def test_set_relays_requires_at_least_one(): cfg_mgr.set_relays([], require_pin=False) +def test_inactivity_timeout_round_trip(): + with TemporaryDirectory() as tmpdir: + vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, Path(tmpdir)) + + cfg = cfg_mgr.load_config(require_pin=False) + assert cfg["inactivity_timeout"] == INACTIVITY_TIMEOUT + + cfg_mgr.set_inactivity_timeout(123) + cfg2 = cfg_mgr.load_config(require_pin=False) + assert cfg2["inactivity_timeout"] == 123 + + def test_password_hash_migrates_from_file(tmp_path): vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) cfg_mgr = ConfigManager(vault, tmp_path) From 57c802c5354a9c7e987484f1589d40de29af5460 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 14:18:29 -0400 Subject: [PATCH 12/43] test: loop index size --- src/tests/test_nostr_index_size.py | 33 ++++++++++++++++++------------ 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/tests/test_nostr_index_size.py b/src/tests/test_nostr_index_size.py index 0e54a06..c2d740e 100644 --- a/src/tests/test_nostr_index_size.py +++ b/src/tests/test_nostr_index_size.py @@ -39,33 +39,40 @@ def test_nostr_index_size_limits(): vault = Vault(enc_mgr, tmpdir) entry_mgr = EntryManager(vault, Path(tmpdir)) - sizes = [16, 64, 256, 1024, 2048, 4096, 8192] delay = float(os.getenv("NOSTR_TEST_DELAY", "5")) - for size in sizes: - try: + size = 16 + entry_count = 0 + max_payload = 60 * 1024 + try: + while True: entry_mgr.add_entry( - website_name=f"site-{size}", + website_name=f"site-{entry_count + 1}", length=12, username="u" * size, url="https://example.com/" + "a" * size, ) + entry_count += 1 encrypted = vault.get_encrypted_index() payload_size = len(encrypted) if encrypted else 0 published = client.publish_json_to_nostr(encrypted or b"") time.sleep(delay) retrieved = client.retrieve_json_from_nostr_sync() retrieved_ok = retrieved == encrypted - results.append((size, payload_size, published, retrieved_ok)) - if not published or not retrieved_ok: + results.append((entry_count, payload_size, published, retrieved_ok)) + if not published or not retrieved_ok or payload_size > max_payload: break - except Exception: - results.append((size, None, False, False)) - break - client.close_client_pool() + size *= 2 + except Exception: + results.append((entry_count + 1, None, False, False)) + finally: + client.close_client_pool() note_kind = Kind.from_std(KindStandard.TEXT_NOTE).as_u16() print(f"\nNostr note Kind: {note_kind}") print(f"Nostr account npub: {npub}") - print("Size | Payload Bytes | Published | Retrieved") - for size, payload, pub, ret in results: - print(f"{size:>4} | {payload:>13} | {pub} | {ret}") + print("Count | Payload Bytes | Published | Retrieved") + for cnt, payload, pub, ret in results: + print(f"{cnt:>5} | {payload:>13} | {pub} | {ret}") + + synced = sum(1 for _, _, pub, ret in results if pub and ret) + print(f"Successfully synced entries: {synced}") From 991f0bfa4c56e5e0552a857bed450654b757c848 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 14:19:34 -0400 Subject: [PATCH 13/43] docs: explain dynamic Nostr index size test --- README.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c2413b0..0a808fc 100644 --- a/README.md +++ b/README.md @@ -239,16 +239,22 @@ pytest -vv ### Exploring Nostr Index Size Limits -The `test_nostr_index_size.py` test probes how large the encrypted index can -be when posted to Nostr. It requires network access and is tagged with -`desktop` and `network`, so run it manually when you want to measure payload -limits: +`test_nostr_index_size.py` now keeps adding entries until either the Nostr +relay or the SDK fails to publish or retrieve the encrypted index. This helps +discover the practical payload ceiling (the loop stops just below the 65 kB +event limit). Because each iteration pushes a larger blob to the relay, the +test is marked with both `desktop` and `network` and is not included in the +default test run. + +Set `NOSTR_TEST_DELAY` to throttle how many seconds the test waits between +publishes. The default is `5` seconds, but you can lengthen it to avoid rate +limits when exploring very large indexes. ```bash -pytest -vv src/tests/test_nostr_index_size.py +NOSTR_TEST_DELAY=10 pytest -vv src/tests/test_nostr_index_size.py -m "desktop and network" ``` -Add `-m "desktop and network"` if you normally exclude those markers. +If you normally exclude those markers, remember to pass `-m "desktop and network"`. ### Automatically Updating the Script Checksum From fce7e8c4b7d97c9fcfb472cd145f3c112aac9be5 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 14:37:45 -0400 Subject: [PATCH 14/43] Add retry and logging for Nostr sync --- src/nostr/client.py | 44 ++++++++++++++++++++++++------ src/tests/test_nostr_index_size.py | 9 ++++++ 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/src/nostr/client.py b/src/nostr/client.py index e8fb4ce..4852179 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -3,6 +3,7 @@ import base64 import json import logging +import time from typing import List, Optional import hashlib import asyncio @@ -69,6 +70,9 @@ class NostrClient: self.relays = relays if relays else DEFAULT_RELAYS + # store the last error encountered during network operations + self.last_error: Optional[str] = None + # Configure and initialize the nostr-sdk Client signer = NostrSigner.keys(self.keys) self.client = Client(signer) @@ -106,6 +110,7 @@ class NostrClient: If provided, include an ``alt`` tag so uploads can be associated with a specific event like a password change. """ + self.last_error = None try: content = base64.b64encode(encrypted_json).decode("utf-8") @@ -130,6 +135,7 @@ class NostrClient: return True except Exception as e: + self.last_error = str(e) logger.error(f"Failed to publish JSON to Nostr: {e}") return False @@ -140,13 +146,33 @@ class NostrClient: async def _publish_event(self, event): return await self.client.send_event(event) - def retrieve_json_from_nostr_sync(self) -> Optional[bytes]: - """Retrieves the latest Kind 1 event from the author.""" - try: - return asyncio.run(self._retrieve_json_from_nostr()) - except Exception as e: - logger.error("Failed to retrieve events from Nostr: %s", e) - return None + def update_relays(self, new_relays: List[str]) -> None: + """Reconnect the client using a new set of relays.""" + self.close_client_pool() + self.relays = new_relays + signer = NostrSigner.keys(self.keys) + self.client = Client(signer) + self.initialize_client_pool() + + def retrieve_json_from_nostr_sync( + self, retries: int = 0, delay: float = 2.0 + ) -> Optional[bytes]: + """Retrieve the latest Kind 1 event from the author with optional retries.""" + self.last_error = None + attempt = 0 + while True: + try: + result = asyncio.run(self._retrieve_json_from_nostr()) + if result is not None: + return result + except Exception as e: + self.last_error = str(e) + logger.error("Failed to retrieve events from Nostr: %s", e) + if attempt >= retries: + break + attempt += 1 + time.sleep(delay) + return None async def _retrieve_json_from_nostr(self) -> Optional[bytes]: # Filter for the latest text note (Kind 1) from our public key @@ -157,7 +183,8 @@ class NostrClient: events = (await self.client.fetch_events(f, timeout)).to_vec() if not events: - logger.warning("No events found on relays for this user.") + self.last_error = "No events found on relays for this user." + logger.warning(self.last_error) return None latest_event = events[0] @@ -165,6 +192,7 @@ class NostrClient: if content_b64: return base64.b64decode(content_b64.encode("utf-8")) + self.last_error = "Latest event contained no content" return None def close_client_pool(self) -> None: diff --git a/src/tests/test_nostr_index_size.py b/src/tests/test_nostr_index_size.py index c2d740e..591a4da 100644 --- a/src/tests/test_nostr_index_size.py +++ b/src/tests/test_nostr_index_size.py @@ -58,6 +58,15 @@ def test_nostr_index_size_limits(): time.sleep(delay) retrieved = client.retrieve_json_from_nostr_sync() retrieved_ok = retrieved == encrypted + if not retrieved_ok: + print(f"Initial retrieve failed: {client.last_error}") + retrieved = client.retrieve_json_from_nostr_sync(retries=1) + retrieved_ok = retrieved == encrypted + if not retrieved_ok: + print("Trying alternate relay") + client.update_relays(["wss://relay.damus.io"]) + retrieved = client.retrieve_json_from_nostr_sync(retries=1) + retrieved_ok = retrieved == encrypted results.append((entry_count, payload_size, published, retrieved_ok)) if not published or not retrieved_ok or payload_size > max_payload: break From d8585bf4efef74171655ba6bc3853b2bb8fe2a41 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 15:01:44 -0400 Subject: [PATCH 15/43] Batch insert entries in nostr size test --- src/tests/test_nostr_index_size.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/tests/test_nostr_index_size.py b/src/tests/test_nostr_index_size.py index 591a4da..e92bcb8 100644 --- a/src/tests/test_nostr_index_size.py +++ b/src/tests/test_nostr_index_size.py @@ -41,17 +41,20 @@ def test_nostr_index_size_limits(): delay = float(os.getenv("NOSTR_TEST_DELAY", "5")) size = 16 + batch = 100 entry_count = 0 max_payload = 60 * 1024 try: while True: - entry_mgr.add_entry( - website_name=f"site-{entry_count + 1}", - length=12, - username="u" * size, - url="https://example.com/" + "a" * size, - ) - entry_count += 1 + for _ in range(batch): + entry_mgr.add_entry( + website_name=f"site-{entry_count + 1}", + length=12, + username="u" * size, + url="https://example.com/" + "a" * size, + ) + entry_count += 1 + encrypted = vault.get_encrypted_index() payload_size = len(encrypted) if encrypted else 0 published = client.publish_json_to_nostr(encrypted or b"") From 52f5ce7f17352f603dbe886935cd199a7f724fdd Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 15:13:32 -0400 Subject: [PATCH 16/43] Use unique fingerprints for Nostr network tests --- src/tests/test_nostr_index_size.py | 3 ++- src/tests/test_nostr_real.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/tests/test_nostr_index_size.py b/src/tests/test_nostr_index_size.py index e92bcb8..a05f087 100644 --- a/src/tests/test_nostr_index_size.py +++ b/src/tests/test_nostr_index_size.py @@ -4,6 +4,7 @@ from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import patch import sys +import uuid import pytest @@ -32,7 +33,7 @@ def test_nostr_index_size_limits(): with patch.object(enc_mgr, "decrypt_parent_seed", return_value=seed): client = NostrClient( enc_mgr, - "size_test_fp", + f"size_test_{uuid.uuid4().hex}", relays=["wss://relay.snort.social"], ) npub = client.key_manager.get_npub() diff --git a/src/tests/test_nostr_real.py b/src/tests/test_nostr_real.py index 18a82bf..df64e2f 100644 --- a/src/tests/test_nostr_real.py +++ b/src/tests/test_nostr_real.py @@ -4,6 +4,7 @@ import time from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import patch +import uuid import pytest from cryptography.fernet import Fernet @@ -26,7 +27,7 @@ def test_nostr_publish_and_retrieve(): with patch.object(enc_mgr, "decrypt_parent_seed", return_value=seed): client = NostrClient( enc_mgr, - "test_fp_real", + f"test_fp_{uuid.uuid4().hex}", relays=["wss://relay.snort.social"], ) payload = b"seedpass" From 81552d5a0e7db2e86e39b2b441680193c91ab967 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 15:49:02 -0400 Subject: [PATCH 17/43] Add nostr backup constants and manifest models --- src/nostr/__init__.py | 17 ++++++++++++++++- src/nostr/backup_models.py | 26 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/nostr/backup_models.py diff --git a/src/nostr/__init__.py b/src/nostr/__init__.py index 6afdc68..4a2d5b4 100644 --- a/src/nostr/__init__.py +++ b/src/nostr/__init__.py @@ -5,9 +5,24 @@ from importlib import import_module import logging +from .backup_models import ( + KIND_MANIFEST, + KIND_SNAPSHOT_CHUNK, + KIND_DELTA, + Manifest, + ChunkMeta, +) + logger = logging.getLogger(__name__) -__all__ = ["NostrClient"] +__all__ = [ + "NostrClient", + "KIND_MANIFEST", + "KIND_SNAPSHOT_CHUNK", + "KIND_DELTA", + "Manifest", + "ChunkMeta", +] def __getattr__(name: str): diff --git a/src/nostr/backup_models.py b/src/nostr/backup_models.py new file mode 100644 index 0000000..2de676c --- /dev/null +++ b/src/nostr/backup_models.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from typing import List, Optional + +# Event kind constants used for SeedPass backups +KIND_MANIFEST = 30070 +KIND_SNAPSHOT_CHUNK = 30071 +KIND_DELTA = 30072 + + +@dataclass +class ChunkMeta: + """Metadata for an individual snapshot chunk.""" + + id: str + size: int + hash: str + + +@dataclass +class Manifest: + """Structure of the backup manifest JSON.""" + + ver: int + algo: str + chunks: List[ChunkMeta] + delta_since: Optional[str] = None From c1bb913d82eb584043b8b3afcb1056fa2904414a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 16:00:55 -0400 Subject: [PATCH 18/43] Add snapshot backup support --- src/nostr/__init__.py | 3 + src/nostr/client.py | 118 ++++++++++++++++++++++++++++++- src/tests/test_nostr_snapshot.py | 98 +++++++++++++++++++++++++ 3 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 src/tests/test_nostr_snapshot.py diff --git a/src/nostr/__init__.py b/src/nostr/__init__.py index 4a2d5b4..e06faea 100644 --- a/src/nostr/__init__.py +++ b/src/nostr/__init__.py @@ -22,10 +22,13 @@ __all__ = [ "KIND_DELTA", "Manifest", "ChunkMeta", + "prepare_snapshot", ] def __getattr__(name: str): if name == "NostrClient": return import_module(".client", __name__).NostrClient + if name == "prepare_snapshot": + return import_module(".client", __name__).prepare_snapshot raise AttributeError(f"module '{__name__}' has no attribute '{name}'") diff --git a/src/nostr/client.py b/src/nostr/client.py index 4852179..2a19a72 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -4,9 +4,10 @@ import base64 import json import logging import time -from typing import List, Optional +from typing import List, Optional, Tuple import hashlib import asyncio +import gzip # Imports from the nostr-sdk library from nostr_sdk import ( @@ -22,6 +23,7 @@ from nostr_sdk import ( from datetime import timedelta from .key_manager import KeyManager as SeedPassKeyManager +from .backup_models import Manifest, ChunkMeta, KIND_MANIFEST, KIND_SNAPSHOT_CHUNK from password_manager.encryption import EncryptionManager from utils.file_lock import exclusive_lock @@ -39,6 +41,44 @@ DEFAULT_RELAYS = [ ] +def prepare_snapshot( + encrypted_bytes: bytes, limit: int +) -> Tuple[Manifest, list[bytes]]: + """Compress and split the encrypted vault into chunks. + + Each chunk is hashed with SHA-256 and described in the returned + :class:`Manifest`. + + Parameters + ---------- + encrypted_bytes : bytes + The encrypted vault contents. + limit : int + Maximum chunk size in bytes. + + Returns + ------- + Tuple[Manifest, list[bytes]] + The manifest describing all chunks and the list of chunk bytes. + """ + + compressed = gzip.compress(encrypted_bytes) + chunks = [compressed[i : i + limit] for i in range(0, len(compressed), limit)] + + metas: list[ChunkMeta] = [] + for i, chunk in enumerate(chunks): + metas.append( + ChunkMeta( + id=f"seedpass-chunk-{i:04d}", + size=len(chunk), + hash=hashlib.sha256(chunk).hexdigest(), + ) + ) + + manifest = Manifest(ver=1, algo="gzip", chunks=metas) + return manifest, chunks + + class NostrClient: """Interact with the Nostr network using nostr-sdk.""" @@ -195,6 +235,82 @@ class NostrClient: self.last_error = "Latest event contained no content" return None + async def publish_snapshot( + self, encrypted_bytes: bytes, limit: int = 50_000 + ) -> Manifest: + """Publish a compressed snapshot split into chunks. + + Parameters + ---------- + encrypted_bytes : bytes + Vault contents already encrypted with the user's key. + limit : int, optional + Maximum chunk size in bytes. Defaults to 50 kB. + """ + + manifest, chunks = prepare_snapshot(encrypted_bytes, limit) + for meta, chunk in zip(manifest.chunks, chunks): + content = base64.b64encode(chunk).decode("utf-8") + builder = EventBuilder(Kind(KIND_SNAPSHOT_CHUNK), content).tags( + [Tag.identifier(meta.id)] + ) + event = builder.build(self.keys.public_key()).sign_with_keys(self.keys) + await self.client.send_event(event) + + manifest_json = json.dumps( + { + "ver": manifest.ver, + "algo": manifest.algo, + "chunks": [meta.__dict__ for meta in manifest.chunks], + "delta_since": manifest.delta_since, + } + ) + + manifest_event = ( + EventBuilder(Kind(KIND_MANIFEST), manifest_json) + .build(self.keys.public_key()) + .sign_with_keys(self.keys) + ) + await self.client.send_event(manifest_event) + return manifest + + async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None: + """Retrieve the latest manifest and all snapshot chunks.""" + + pubkey = self.keys.public_key() + f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).limit(1) + timeout = timedelta(seconds=10) + events = (await self.client.fetch_events(f, timeout)).to_vec() + if not events: + return None + manifest_raw = events[0].content() + data = json.loads(manifest_raw) + manifest = Manifest( + ver=data["ver"], + algo=data["algo"], + chunks=[ChunkMeta(**c) for c in data["chunks"]], + delta_since=data.get("delta_since"), + ) + + chunks: list[bytes] = [] + for meta in manifest.chunks: + cf = ( + Filter() + .author(pubkey) + .kind(Kind(KIND_SNAPSHOT_CHUNK)) + .identifier(meta.id) + .limit(1) + ) + cev = (await self.client.fetch_events(cf, timeout)).to_vec() + if not cev: + raise ValueError(f"Missing chunk {meta.id}") + chunk_bytes = base64.b64decode(cev[0].content().encode("utf-8")) + if hashlib.sha256(chunk_bytes).hexdigest() != meta.hash: + raise ValueError(f"Checksum mismatch for chunk {meta.id}") + chunks.append(chunk_bytes) + + return manifest, chunks + def close_client_pool(self) -> None: """Disconnects the client from all relays.""" try: diff --git a/src/tests/test_nostr_snapshot.py b/src/tests/test_nostr_snapshot.py new file mode 100644 index 0000000..3d60560 --- /dev/null +++ b/src/tests/test_nostr_snapshot.py @@ -0,0 +1,98 @@ +import hashlib +import json +import gzip +from pathlib import Path +from tempfile import TemporaryDirectory +from cryptography.fernet import Fernet +import base64 +import asyncio +from unittest.mock import patch + +from nostr import prepare_snapshot, NostrClient +from password_manager.encryption import EncryptionManager + + +def test_prepare_snapshot_roundtrip(): + data = b"a" * 70000 + manifest, chunks = prepare_snapshot(data, 50000) + assert len(chunks) == len(manifest.chunks) + joined = b"".join(chunks) + assert len(joined) <= len(data) + assert hashlib.sha256(chunks[0]).hexdigest() == manifest.chunks[0].hash + assert manifest.chunks[0].id == "seedpass-chunk-0000" + assert data == gzip.decompress(joined) + + +class DummyEvent: + def __init__(self, content): + self._content = content + + def content(self): + return self._content + + +class DummyClient: + def __init__(self, events): + self.events = events + self.pos = 0 + + async def add_relays(self, relays): + pass + + async def add_relay(self, relay): + pass + + async def connect(self): + pass + + async def disconnect(self): + pass + + async def send_event(self, event): + pass + + async def fetch_events(self, f, timeout): + ev = self.events[self.pos] + self.pos += 1 + + class E: + def __init__(self, ev): + self._ev = ev + + def to_vec(self): + return [self._ev] + + return E(ev) + + +def test_fetch_latest_snapshot(): + data = b"seedpass" * 1000 + manifest, chunks = prepare_snapshot(data, 50000) + manifest_json = json.dumps( + { + "ver": manifest.ver, + "algo": manifest.algo, + "chunks": [c.__dict__ for c in manifest.chunks], + "delta_since": None, + } + ) + events = [DummyEvent(manifest_json)] + [ + DummyEvent(base64.b64encode(c).decode()) for c in chunks + ] + + client = DummyClient(events) + with TemporaryDirectory() as tmpdir: + enc_mgr = EncryptionManager(Fernet.generate_key(), Path(tmpdir)) + with patch("nostr.client.Client", lambda signer: client), patch( + "nostr.client.KeyManager" + ) as MockKM, patch.object(NostrClient, "initialize_client_pool"), patch.object( + enc_mgr, "decrypt_parent_seed", return_value="seed" + ): + km = MockKM.return_value + km.keys.private_key_hex.return_value = "1" * 64 + km.keys.public_key_hex.return_value = "2" * 64 + nc = NostrClient(enc_mgr, "fp") + result_manifest, result_chunks = asyncio.run(nc.fetch_latest_snapshot()) + + assert manifest == result_manifest + assert result_chunks == chunks From 456de50ff17630bea49a5e873afc7b4d1e7bc7be Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 16:11:05 -0400 Subject: [PATCH 19/43] Add delta publishing and fetching --- src/nostr/client.py | 58 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/nostr/client.py b/src/nostr/client.py index 2a19a72..c2aee6e 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -21,6 +21,7 @@ from nostr_sdk import ( Tag, ) from datetime import timedelta +from nostr_sdk import EventId, Timestamp from .key_manager import KeyManager as SeedPassKeyManager from .backup_models import Manifest, ChunkMeta, KIND_MANIFEST, KIND_SNAPSHOT_CHUNK @@ -113,6 +114,10 @@ class NostrClient: # store the last error encountered during network operations self.last_error: Optional[str] = None + self.delta_threshold = 100 + self.current_manifest: Manifest | None = None + self._delta_events: list[str] = [] + # Configure and initialize the nostr-sdk Client signer = NostrSigner.keys(self.keys) self.client = Client(signer) @@ -272,6 +277,8 @@ class NostrClient: .sign_with_keys(self.keys) ) await self.client.send_event(manifest_event) + self.current_manifest = manifest + self._delta_events = [] return manifest async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None: @@ -309,8 +316,59 @@ class NostrClient: raise ValueError(f"Checksum mismatch for chunk {meta.id}") chunks.append(chunk_bytes) + self.current_manifest = manifest return manifest, chunks + async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str: + """Publish a delta event referencing a manifest.""" + + content = base64.b64encode(delta_bytes).decode("utf-8") + tag = Tag.event(EventId.parse(manifest_id)) + builder = EventBuilder(Kind(KIND_DELTA), content).tags([tag]) + event = builder.build(self.keys.public_key()).sign_with_keys(self.keys) + result = await self.client.send_event(event) + delta_id = result.id.to_hex() if hasattr(result, "id") else str(result) + if self.current_manifest is not None: + self.current_manifest.delta_since = delta_id + self._delta_events.append(delta_id) + return delta_id + + async def fetch_deltas_since(self, version: int) -> list[bytes]: + """Retrieve delta events newer than the given version.""" + + pubkey = self.keys.public_key() + f = ( + Filter() + .author(pubkey) + .kind(Kind(KIND_DELTA)) + .since(Timestamp.from_secs(version)) + ) + timeout = timedelta(seconds=10) + events = (await self.client.fetch_events(f, timeout)).to_vec() + deltas: list[bytes] = [] + for ev in events: + deltas.append(base64.b64decode(ev.content().encode("utf-8"))) + + if self.current_manifest is not None: + snap_size = sum(c.size for c in self.current_manifest.chunks) + if ( + len(deltas) >= self.delta_threshold + or sum(len(d) for d in deltas) > snap_size + ): + # Publish a new snapshot to consolidate deltas + joined = b"".join(deltas) + await self.publish_snapshot(joined) + exp = Timestamp.from_secs(int(time.time())) + for ev in events: + exp_builder = EventBuilder(Kind(KIND_DELTA), ev.content()).tags( + [Tag.expiration(exp)] + ) + exp_event = exp_builder.build( + self.keys.public_key() + ).sign_with_keys(self.keys) + await self.client.send_event(exp_event) + return deltas + def close_client_pool(self) -> None: """Disconnects the client from all relays.""" try: From 678cdbc5e68da0574bd0243673a101c79332f8ee Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 16:22:57 -0400 Subject: [PATCH 20/43] Add vault sync and update Nostr restore --- src/main.py | 44 +++++++------ src/password_manager/manager.py | 83 ++++++++++++++---------- src/password_manager/portable_backup.py | 3 +- src/tests/test_encryption_mode_change.py | 5 +- src/tests/test_manager_workflow.py | 4 +- src/tests/test_password_change.py | 5 +- src/tests/test_post_sync_messages.py | 10 +-- 7 files changed, 84 insertions(+), 70 deletions(-) diff --git a/src/main.py b/src/main.py index 3ba849f..d1bec58 100644 --- a/src/main.py +++ b/src/main.py @@ -7,6 +7,8 @@ import signal import getpass import time import argparse +import asyncio +import gzip import tomli from colorama import init as colorama_init from termcolor import colored @@ -225,23 +227,13 @@ def handle_post_to_nostr( Handles the action of posting the encrypted password index to Nostr. """ try: - # Get the encrypted data from the index file - encrypted_data = password_manager.get_encrypted_data() - if encrypted_data: - # Post to Nostr - success = password_manager.nostr_client.publish_json_to_nostr( - encrypted_data, - alt_summary=alt_summary, - ) - if success: - print(colored("\N{WHITE HEAVY CHECK MARK} Sync complete.", "green")) - logging.info("Encrypted index posted to Nostr successfully.") - else: - print(colored("\N{CROSS MARK} Sync failed…", "red")) - logging.error("Failed to post encrypted index to Nostr.") + success = password_manager.sync_vault(alt_summary=alt_summary) + if success: + print(colored("\N{WHITE HEAVY CHECK MARK} Sync complete.", "green")) + logging.info("Encrypted index posted to Nostr successfully.") else: - print(colored("No data available to post.", "yellow")) - logging.warning("No data available to post to Nostr.") + print(colored("\N{CROSS MARK} Sync failed…", "red")) + logging.error("Failed to post encrypted index 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")) @@ -252,12 +244,22 @@ def handle_retrieve_from_nostr(password_manager: PasswordManager): Handles the action of retrieving the encrypted password index from Nostr. """ try: - # Use the Nostr client from the password_manager - encrypted_data = password_manager.nostr_client.retrieve_json_from_nostr_sync() - if encrypted_data: - # Decrypt and save the index + result = asyncio.run(password_manager.nostr_client.fetch_latest_snapshot()) + if result: + manifest, chunks = result + encrypted = gzip.decompress(b"".join(chunks)) + if manifest.delta_since: + try: + version = int(manifest.delta_since) + deltas = asyncio.run( + password_manager.nostr_client.fetch_deltas_since(version) + ) + if deltas: + encrypted = deltas[-1] + except ValueError: + pass password_manager.encryption_manager.decrypt_and_save_index_from_nostr( - encrypted_data + encrypted ) print(colored("Encrypted index retrieved and saved successfully.", "green")) logging.info("Encrypted index retrieved and saved successfully from Nostr.") diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 7c7ffb6..59ad46d 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -55,6 +55,8 @@ from constants import ( ) import traceback +import asyncio +import gzip import bcrypt from pathlib import Path @@ -812,8 +814,20 @@ class PasswordManager: if index_file.exists(): return try: - encrypted = self.nostr_client.retrieve_json_from_nostr_sync() - if encrypted: + result = asyncio.run(self.nostr_client.fetch_latest_snapshot()) + if result: + manifest, chunks = result + encrypted = gzip.decompress(b"".join(chunks)) + if manifest.delta_since: + try: + version = int(manifest.delta_since) + deltas = asyncio.run( + self.nostr_client.fetch_deltas_since(version) + ) + if deltas: + encrypted = deltas[-1] + except ValueError: + pass self.vault.decrypt_and_save_index_from_nostr(encrypted) logger.info("Initialized local database from Nostr.") except Exception as e: @@ -871,12 +885,8 @@ class PasswordManager: # Automatically push the updated encrypted index to Nostr so the # latest changes are backed up remotely. try: - encrypted_data = self.get_encrypted_data() - if encrypted_data: - self.nostr_client.publish_json_to_nostr(encrypted_data) - logging.info( - "Encrypted index posted to Nostr after entry addition." - ) + self.sync_vault() + logging.info("Encrypted index posted to Nostr after entry addition.") except Exception as nostr_error: logging.error( f"Failed to post updated index to Nostr: {nostr_error}", @@ -1040,12 +1050,10 @@ class PasswordManager: # Push the updated index to Nostr so changes are backed up. try: - encrypted_data = self.get_encrypted_data() - if encrypted_data: - self.nostr_client.publish_json_to_nostr(encrypted_data) - logging.info( - "Encrypted index posted to Nostr after entry modification." - ) + self.sync_vault() + logging.info( + "Encrypted index posted to Nostr after entry modification." + ) except Exception as nostr_error: logging.error( f"Failed to post updated index to Nostr: {nostr_error}", @@ -1081,12 +1089,8 @@ class PasswordManager: # Push updated index to Nostr after deletion try: - encrypted_data = self.get_encrypted_data() - if encrypted_data: - self.nostr_client.publish_json_to_nostr(encrypted_data) - logging.info( - "Encrypted index posted to Nostr after entry deletion." - ) + self.sync_vault() + logging.info("Encrypted index posted to Nostr after entry deletion.") except Exception as nostr_error: logging.error( f"Failed to post updated index to Nostr: {nostr_error}", @@ -1172,6 +1176,27 @@ class PasswordManager: # Re-raise the exception to inform the calling function of the failure raise + def sync_vault(self, alt_summary: str | None = None) -> bool: + """Publish the current vault contents to Nostr.""" + try: + encrypted = self.get_encrypted_data() + if not encrypted: + return False + pub_snap = getattr(self.nostr_client, "publish_snapshot", None) + if callable(pub_snap): + if asyncio.iscoroutinefunction(pub_snap): + asyncio.run(pub_snap(encrypted)) + else: + pub_snap(encrypted) + else: + # Fallback for tests using simplified stubs + self.nostr_client.publish_json_to_nostr(encrypted) + self.is_dirty = False + return True + except Exception as e: + logging.error(f"Failed to sync vault: {e}", exc_info=True) + return False + def backup_database(self) -> None: """ Creates a backup of the encrypted JSON index file. @@ -1451,13 +1476,8 @@ class PasswordManager: # Push a fresh backup to Nostr so the newly encrypted index is # stored remotely. Include a tag to mark the password change. try: - encrypted_data = self.get_encrypted_data() - if encrypted_data: - summary = f"password-change-{int(time.time())}" - self.nostr_client.publish_json_to_nostr( - encrypted_data, - alt_summary=summary, - ) + summary = f"password-change-{int(time.time())}" + self.sync_vault(alt_summary=summary) except Exception as nostr_error: logging.error( f"Failed to post updated index to Nostr after password change: {nostr_error}" @@ -1501,13 +1521,8 @@ class PasswordManager: print(colored("Encryption mode changed successfully.", "green")) try: - encrypted_data = self.get_encrypted_data() - if encrypted_data: - summary = f"mode-change-{int(time.time())}" - self.nostr_client.publish_json_to_nostr( - encrypted_data, - alt_summary=summary, - ) + summary = f"mode-change-{int(time.time())}" + self.sync_vault(alt_summary=summary) except Exception as nostr_error: logging.error( f"Failed to post updated index to Nostr after encryption mode change: {nostr_error}" diff --git a/src/password_manager/portable_backup.py b/src/password_manager/portable_backup.py index 5f9df7e..1c4a9eb 100644 --- a/src/password_manager/portable_backup.py +++ b/src/password_manager/portable_backup.py @@ -8,6 +8,7 @@ import json import logging import os import time +import asyncio from enum import Enum from pathlib import Path @@ -103,7 +104,7 @@ def export_backup( os.chmod(enc_file, 0o600) try: client = NostrClient(vault.encryption_manager, vault.fingerprint_dir.name) - client.publish_json_to_nostr(encrypted) + asyncio.run(client.publish_snapshot(encrypted)) except Exception: logger.error("Failed to publish backup via Nostr", exc_info=True) diff --git a/src/tests/test_encryption_mode_change.py b/src/tests/test_encryption_mode_change.py index b2c225e..e91ec0a 100644 --- a/src/tests/test_encryption_mode_change.py +++ b/src/tests/test_encryption_mode_change.py @@ -2,7 +2,7 @@ import sys from pathlib import Path from tempfile import TemporaryDirectory from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import patch, AsyncMock from helpers import create_vault, TEST_SEED, TEST_PASSWORD @@ -44,9 +44,10 @@ def test_change_encryption_mode(monkeypatch): with patch("password_manager.manager.NostrClient") as MockClient: mock = MockClient.return_value + mock.publish_snapshot = AsyncMock(return_value=None) pm.nostr_client = mock pm.change_encryption_mode(EncryptionMode.SEED_PLUS_PW) - mock.publish_json_to_nostr.assert_called_once() + mock.publish_snapshot.assert_called_once() assert pm.encryption_mode is EncryptionMode.SEED_PLUS_PW assert pm.password_generator.encryption_manager is pm.encryption_manager diff --git a/src/tests/test_manager_workflow.py b/src/tests/test_manager_workflow.py index d651336..9eb845f 100644 --- a/src/tests/test_manager_workflow.py +++ b/src/tests/test_manager_workflow.py @@ -60,7 +60,7 @@ def test_manager_workflow(monkeypatch): monkeypatch.setattr("builtins.input", lambda *args, **kwargs: next(inputs)) pm.handle_add_password() - assert pm.is_dirty is True + assert pm.is_dirty is False backups = list(tmp_path.glob("passwords_db_backup_*.json.enc")) assert len(backups) == 1 checksum_file = tmp_path / "seedpass_passwords_db_checksum.txt" @@ -73,7 +73,7 @@ def test_manager_workflow(monkeypatch): assert pm.is_dirty is False pm.handle_modify_entry() - assert pm.is_dirty is True + assert pm.is_dirty is False pm.backup_manager.create_backup() backup_dir = tmp_path / "backups" backups_mod = list(backup_dir.glob("passwords_db_backup_*.json.enc")) diff --git a/src/tests/test_password_change.py b/src/tests/test_password_change.py index 03d66e5..85ee9a6 100644 --- a/src/tests/test_password_change.py +++ b/src/tests/test_password_change.py @@ -2,7 +2,7 @@ import sys from pathlib import Path from tempfile import TemporaryDirectory from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import patch, AsyncMock from helpers import create_vault, TEST_SEED, TEST_PASSWORD @@ -42,6 +42,7 @@ def test_change_password_triggers_nostr_backup(monkeypatch): with patch("password_manager.manager.NostrClient") as MockClient: mock_instance = MockClient.return_value + mock_instance.publish_snapshot = AsyncMock(return_value=None) pm.nostr_client = mock_instance pm.change_password() - mock_instance.publish_json_to_nostr.assert_called_once() + mock_instance.publish_snapshot.assert_called_once() diff --git a/src/tests/test_post_sync_messages.py b/src/tests/test_post_sync_messages.py index 05b2194..2a4e95e 100644 --- a/src/tests/test_post_sync_messages.py +++ b/src/tests/test_post_sync_messages.py @@ -9,10 +9,7 @@ import main def test_handle_post_success(capsys): pm = SimpleNamespace( - get_encrypted_data=lambda: b"data", - nostr_client=SimpleNamespace( - publish_json_to_nostr=lambda data, alt_summary=None: True - ), + sync_vault=lambda alt_summary=None: True, ) main.handle_post_to_nostr(pm) out = capsys.readouterr().out @@ -21,10 +18,7 @@ def test_handle_post_success(capsys): def test_handle_post_failure(capsys): pm = SimpleNamespace( - get_encrypted_data=lambda: b"data", - nostr_client=SimpleNamespace( - publish_json_to_nostr=lambda data, alt_summary=None: False - ), + sync_vault=lambda alt_summary=None: False, ) main.handle_post_to_nostr(pm) out = capsys.readouterr().out From 53166d453b741731971cdf39f98eb9d082e01e85 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 17:06:03 -0400 Subject: [PATCH 21/43] Update tests for new nostr API --- src/tests/test_encryption_mode_migration.py | 6 +-- src/tests/test_manager_workflow.py | 3 +- src/tests/test_nostr_backup.py | 12 +++-- src/tests/test_nostr_contract.py | 21 +++++--- src/tests/test_nostr_index_size.py | 21 +++++--- src/tests/test_nostr_real.py | 7 ++- .../test_password_unlock_after_change.py | 6 +-- src/tests/test_profile_management.py | 4 +- src/tests/test_publish_json_result.py | 53 ++++++++++++++----- src/tests/test_settings_menu.py | 2 +- 10 files changed, 87 insertions(+), 48 deletions(-) diff --git a/src/tests/test_encryption_mode_migration.py b/src/tests/test_encryption_mode_migration.py index 4427f89..1970585 100644 --- a/src/tests/test_encryption_mode_migration.py +++ b/src/tests/test_encryption_mode_migration.py @@ -57,7 +57,7 @@ def test_encryption_mode_migration(monkeypatch, start_mode, new_mode): pm.current_fingerprint = "fp" pm.parent_seed = TEST_SEED pm.encryption_mode = start_mode - pm.nostr_client = SimpleNamespace(publish_json_to_nostr=lambda *a, **k: None) + pm.nostr_client = SimpleNamespace(publish_snapshot=lambda *a, **k: None) monkeypatch.setattr( "password_manager.manager.prompt_existing_password", @@ -65,9 +65,7 @@ def test_encryption_mode_migration(monkeypatch, start_mode, new_mode): ) monkeypatch.setattr( "password_manager.manager.NostrClient", - lambda *a, **kw: SimpleNamespace( - publish_json_to_nostr=lambda *a, **k: None - ), + lambda *a, **kw: SimpleNamespace(publish_snapshot=lambda *a, **k: None), ) pm.change_encryption_mode(new_mode) diff --git a/src/tests/test_manager_workflow.py b/src/tests/test_manager_workflow.py index 9eb845f..7bc1f91 100644 --- a/src/tests/test_manager_workflow.py +++ b/src/tests/test_manager_workflow.py @@ -20,9 +20,8 @@ class FakeNostrClient: def __init__(self, *args, **kwargs): self.published = [] - def publish_json_to_nostr(self, data: bytes): + def publish_snapshot(self, data: bytes): self.published.append(data) - return True def test_manager_workflow(monkeypatch): diff --git a/src/tests/test_nostr_backup.py b/src/tests/test_nostr_backup.py index a7c966c..b9faca4 100644 --- a/src/tests/test_nostr_backup.py +++ b/src/tests/test_nostr_backup.py @@ -1,7 +1,8 @@ import sys from pathlib import Path from tempfile import TemporaryDirectory -from unittest.mock import patch +from unittest.mock import patch, AsyncMock +import asyncio from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) @@ -23,7 +24,8 @@ def test_backup_and_publish_to_nostr(): assert encrypted_index is not None with patch( - "nostr.client.NostrClient.publish_json_to_nostr", return_value=True + "nostr.client.NostrClient.publish_snapshot", + AsyncMock(return_value=None), ) as mock_publish, patch("nostr.client.ClientBuilder"), patch( "nostr.client.KeyManager" ), patch.object( @@ -33,7 +35,7 @@ def test_backup_and_publish_to_nostr(): ): nostr_client = NostrClient(enc_mgr, "fp") entry_mgr.backup_index_file() - result = nostr_client.publish_json_to_nostr(encrypted_index) + result = asyncio.run(nostr_client.publish_snapshot(encrypted_index)) - mock_publish.assert_called_with(encrypted_index) - assert result is True + mock_publish.assert_awaited_with(encrypted_index) + assert result is None diff --git a/src/tests/test_nostr_contract.py b/src/tests/test_nostr_contract.py index 2d3d106..29a974c 100644 --- a/src/tests/test_nostr_contract.py +++ b/src/tests/test_nostr_contract.py @@ -1,12 +1,14 @@ import sys from pathlib import Path from unittest.mock import patch +import asyncio +import gzip 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 +from nostr.client import NostrClient, Manifest class MockNostrServer: @@ -17,6 +19,7 @@ class MockNostrServer: class MockClient: def __init__(self, server): self.server = server + self.pos = -1 async def add_relays(self, relays): pass @@ -44,14 +47,17 @@ class MockClient: return FakeOutput() async def fetch_events(self, filter_obj, timeout): + ev = self.server.events[self.pos] + self.pos -= 1 + class FakeEvents: - def __init__(self, events): - self._events = events + def __init__(self, event): + self._event = event def to_vec(self): - return self._events + return [self._event] - return FakeEvents(self.server.events[-1:]) + return FakeEvents(ev) def setup_client(tmp_path, server): @@ -72,5 +78,6 @@ def test_publish_and_retrieve(tmp_path): server = MockNostrServer() client = setup_client(tmp_path, server) payload = b"contract-test" - assert client.publish_json_to_nostr(payload) is True - assert client.retrieve_json_from_nostr_sync() == payload + asyncio.run(client.publish_snapshot(payload)) + manifest, chunks = asyncio.run(client.fetch_latest_snapshot()) + assert gzip.decompress(b"".join(chunks)) == payload diff --git a/src/tests/test_nostr_index_size.py b/src/tests/test_nostr_index_size.py index a05f087..00dc430 100644 --- a/src/tests/test_nostr_index_size.py +++ b/src/tests/test_nostr_index_size.py @@ -3,6 +3,8 @@ import time from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import patch +import asyncio +import gzip import sys import uuid @@ -58,21 +60,28 @@ def test_nostr_index_size_limits(): encrypted = vault.get_encrypted_index() payload_size = len(encrypted) if encrypted else 0 - published = client.publish_json_to_nostr(encrypted or b"") + asyncio.run(client.publish_snapshot(encrypted or b"")) time.sleep(delay) - retrieved = client.retrieve_json_from_nostr_sync() + result = asyncio.run(client.fetch_latest_snapshot()) + retrieved = gzip.decompress(b"".join(result[1])) if result else None retrieved_ok = retrieved == encrypted if not retrieved_ok: print(f"Initial retrieve failed: {client.last_error}") - retrieved = client.retrieve_json_from_nostr_sync(retries=1) + result = asyncio.run(client.fetch_latest_snapshot()) + retrieved = ( + gzip.decompress(b"".join(result[1])) if result else None + ) retrieved_ok = retrieved == encrypted if not retrieved_ok: print("Trying alternate relay") client.update_relays(["wss://relay.damus.io"]) - retrieved = client.retrieve_json_from_nostr_sync(retries=1) + result = asyncio.run(client.fetch_latest_snapshot()) + retrieved = ( + gzip.decompress(b"".join(result[1])) if result else None + ) retrieved_ok = retrieved == encrypted - results.append((entry_count, payload_size, published, retrieved_ok)) - if not published or not retrieved_ok or payload_size > max_payload: + results.append((entry_count, payload_size, True, retrieved_ok)) + if not retrieved_ok or payload_size > max_payload: break size *= 2 except Exception: diff --git a/src/tests/test_nostr_real.py b/src/tests/test_nostr_real.py index df64e2f..97b466b 100644 --- a/src/tests/test_nostr_real.py +++ b/src/tests/test_nostr_real.py @@ -4,6 +4,8 @@ import time from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import patch +import asyncio +import gzip import uuid import pytest @@ -31,8 +33,9 @@ def test_nostr_publish_and_retrieve(): relays=["wss://relay.snort.social"], ) payload = b"seedpass" - assert client.publish_json_to_nostr(payload) is True + asyncio.run(client.publish_snapshot(payload)) time.sleep(2) - retrieved = client.retrieve_json_from_nostr_sync() + result = asyncio.run(client.fetch_latest_snapshot()) + retrieved = gzip.decompress(b"".join(result[1])) if result else None client.close_client_pool() assert retrieved == payload diff --git a/src/tests/test_password_unlock_after_change.py b/src/tests/test_password_unlock_after_change.py index ac717c1..17e21b5 100644 --- a/src/tests/test_password_unlock_after_change.py +++ b/src/tests/test_password_unlock_after_change.py @@ -54,7 +54,7 @@ def test_password_change_and_unlock(monkeypatch): pm.fingerprint_dir = fp pm.current_fingerprint = "fp" pm.parent_seed = SEED - pm.nostr_client = SimpleNamespace(publish_json_to_nostr=lambda *a, **k: None) + pm.nostr_client = SimpleNamespace(publish_snapshot=lambda *a, **k: None) monkeypatch.setattr( "password_manager.manager.prompt_existing_password", lambda *_: old_pw @@ -64,9 +64,7 @@ def test_password_change_and_unlock(monkeypatch): ) monkeypatch.setattr( "password_manager.manager.NostrClient", - lambda *a, **kw: SimpleNamespace( - publish_json_to_nostr=lambda *a, **k: None - ), + lambda *a, **kw: SimpleNamespace(publish_snapshot=lambda *a, **k: None), ) pm.change_password() diff --git a/src/tests/test_profile_management.py b/src/tests/test_profile_management.py index a413c88..906a789 100644 --- a/src/tests/test_profile_management.py +++ b/src/tests/test_profile_management.py @@ -60,9 +60,7 @@ def test_add_and_delete_entry(monkeypatch): published = [] pm.nostr_client = SimpleNamespace( - publish_json_to_nostr=lambda data, alt_summary=None: ( - published.append(data) or True - ) + publish_snapshot=lambda data, alt_summary=None: published.append(data) ) inputs = iter([str(index)]) diff --git a/src/tests/test_publish_json_result.py b/src/tests/test_publish_json_result.py index c2c7e03..08f97ce 100644 --- a/src/tests/test_publish_json_result.py +++ b/src/tests/test_publish_json_result.py @@ -2,12 +2,14 @@ import sys from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import patch +import asyncio +import pytest 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 +from nostr.client import NostrClient, Manifest def setup_client(tmp_path): @@ -27,21 +29,34 @@ def setup_client(tmp_path): class FakeEvent: - def __init__(self): + def __init__(self, content="evt"): self._id = "id" + self._content = content def id(self): return self._id + def content(self): + return self._content + class FakeUnsignedEvent: + def __init__(self, content="evt"): + self._content = content + def sign_with_keys(self, _): - return FakeEvent() + return FakeEvent(self._content) class FakeBuilder: + def __init__(self, _kind=None, content="evt"): + self._content = content + + def tags(self, _tags): + return self + def build(self, _): - return FakeUnsignedEvent() + return FakeUnsignedEvent(self._content) class FakeEventId: @@ -54,22 +69,32 @@ class FakeSendEventOutput: self.id = FakeEventId() -def test_publish_json_success(): +def test_publish_snapshot_success(): with TemporaryDirectory() as tmpdir, patch( - "nostr.client.EventBuilder.text_note", return_value=FakeBuilder() + "nostr.client.EventBuilder", FakeBuilder ): client = setup_client(Path(tmpdir)) + + async def fake_send(event): + return FakeSendEventOutput() + with patch.object( - client, "publish_event", return_value=FakeSendEventOutput() - ) as mock_pub: - assert client.publish_json_to_nostr(b"data") is True - mock_pub.assert_called() + client.client, "send_event", side_effect=fake_send + ) as mock_send: + manifest = asyncio.run(client.publish_snapshot(b"data")) + assert isinstance(manifest, Manifest) + assert mock_send.await_count >= 1 -def test_publish_json_failure(): +def test_publish_snapshot_failure(): with TemporaryDirectory() as tmpdir, patch( - "nostr.client.EventBuilder.text_note", return_value=FakeBuilder() + "nostr.client.EventBuilder", FakeBuilder ): client = setup_client(Path(tmpdir)) - with patch.object(client, "publish_event", side_effect=Exception("boom")): - assert client.publish_json_to_nostr(b"data") is False + + async def boom(_): + raise Exception("boom") + + with patch.object(client.client, "send_event", side_effect=boom): + with pytest.raises(Exception): + asyncio.run(client.publish_snapshot(b"data")) diff --git a/src/tests/test_settings_menu.py b/src/tests/test_settings_menu.py index 7a0c0ee..668cc04 100644 --- a/src/tests/test_settings_menu.py +++ b/src/tests/test_settings_menu.py @@ -33,7 +33,7 @@ def setup_pm(tmp_path, monkeypatch): relays=list(DEFAULT_RELAYS), close_client_pool=lambda: None, initialize_client_pool=lambda: None, - publish_json_to_nostr=lambda data, alt_summary=None: None, + publish_snapshot=lambda data, alt_summary=None: None, key_manager=SimpleNamespace(get_npub=lambda: "npub"), ) From eb0dac7b6222546f8d2fa7141dd2b65665504511 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 17:26:51 -0400 Subject: [PATCH 22/43] Add dummy Nostr relay fixtures and tests --- src/tests/helpers.py | 225 +++++++++++++++++++++++++++ src/tests/test_nostr_dummy_client.py | 50 ++++++ 2 files changed, 275 insertions(+) create mode 100644 src/tests/test_nostr_dummy_client.py diff --git a/src/tests/helpers.py b/src/tests/helpers.py index 22c55cf..b6d61ec 100644 --- a/src/tests/helpers.py +++ b/src/tests/helpers.py @@ -30,3 +30,228 @@ def create_vault( enc_mgr = EncryptionManager(index_key, dir_path) vault = Vault(enc_mgr, dir_path) return vault, enc_mgr + + +import uuid +import asyncio +import pytest + +from nostr.backup_models import ( + KIND_MANIFEST, + KIND_SNAPSHOT_CHUNK, + KIND_DELTA, +) + + +class DummyEvent: + def __init__(self, kind: int, content: str, tags=None, event_id: str | None = None): + self.kind = kind + self._content = content + self.tags = tags or [] + self.id = event_id or f"evt-{uuid.uuid4().hex}" + + def content(self): + return self._content + + +class DummyUnsignedEvent: + def __init__(self, kind: int, content: str, tags: list[str]): + self.kind = kind + self.content = content + self.tags = tags + + def sign_with_keys(self, _keys): + return DummyEvent(self.kind, self.content, self.tags) + + +class DummyBuilder: + def __init__(self, kind=None, content=""): + if hasattr(kind, "as_u16"): + self.kind = kind.as_u16() + elif hasattr(kind, "value"): + self.kind = kind.value + else: + self.kind = int(kind) + self.content = content + self._tags: list[str] = [] + + def tags(self, tags): + # store raw tag values + self._tags.extend(tags) + return self + + def build(self, _pk): + return DummyUnsignedEvent(self.kind, self.content, self._tags) + + +class DummyTag: + @staticmethod + def identifier(value): + return value + + @staticmethod + def event(value): + return value + + @staticmethod + def alt(value): + return value + + @staticmethod + def expiration(value): + return value + + +class DummyFilter: + def __init__(self): + self.kind_val: int | None = None + self.ids: list[str] = [] + self.limit_val: int | None = None + self.since_val: int | None = None + + def author(self, _pk): + return self + + def kind(self, kind): + if hasattr(kind, "as_u16"): + self.kind_val = kind.as_u16() + elif hasattr(kind, "value"): + self.kind_val = kind.value + else: + self.kind_val = int(kind) + return self + + def identifier(self, ident: str): + self.ids.append(ident) + return self + + def limit(self, val: int): + self.limit_val = val + return self + + def since(self, ts): + self.since_val = getattr(ts, "secs", ts) + return self + + +class DummyTimestamp: + def __init__(self, secs: int): + self.secs = secs + + @staticmethod + def from_secs(secs: int) -> "DummyTimestamp": + return DummyTimestamp(secs) + + +class DummyEventId: + def __init__(self, val: str): + self.val = val + + def to_hex(self) -> str: + return self.val + + @staticmethod + def parse(val: str) -> str: + return val + + +class DummySendResult: + def __init__(self, event_id: str): + self.id = DummyEventId(event_id) + + +class DummyRelayClient: + def __init__(self): + self.counter = 0 + self.manifests: list[DummyEvent] = [] + self.chunks: dict[str, DummyEvent] = {} + self.deltas: list[DummyEvent] = [] + + async def add_relays(self, _relays): + pass + + async def add_relay(self, _relay): + pass + + async def connect(self): + pass + + async def disconnect(self): + pass + + async def send_event(self, event): + self.counter += 1 + eid = str(self.counter) + if isinstance(event, DummyEvent): + event.id = eid + if event.kind == KIND_MANIFEST: + self.manifests.append(event) + elif event.kind == KIND_SNAPSHOT_CHUNK: + ident = event.tags[0] if event.tags else str(self.counter) + self.chunks[ident] = event + elif event.kind == KIND_DELTA: + self.deltas.append(event) + return DummySendResult(eid) + + async def fetch_events(self, f, _timeout): + kind = getattr(f, "kind_val", None) + limit = getattr(f, "limit_val", None) + identifier = f.ids[0] if getattr(f, "ids", None) else None + since = getattr(f, "since_val", None) + events: list[DummyEvent] = [] + if kind == KIND_MANIFEST: + events = list(reversed(self.manifests)) + elif kind == KIND_SNAPSHOT_CHUNK and identifier is not None: + if identifier in self.chunks: + events = [self.chunks[identifier]] + elif kind == KIND_DELTA: + events = [d for d in self.deltas if since is None or int(d.id) > since] + if limit is not None: + events = events[:limit] + + class Result: + def __init__(self, evs): + self._evs = evs + + def to_vec(self): + return self._evs + + return Result(events) + + +@pytest.fixture +def dummy_nostr_client(tmp_path, monkeypatch): + """Return a NostrClient wired to a DummyRelayClient.""" + from cryptography.fernet import Fernet + from nostr.client import NostrClient + + relay = DummyRelayClient() + monkeypatch.setattr("nostr.client.Client", lambda signer: relay) + monkeypatch.setattr("nostr.client.EventBuilder", DummyBuilder) + monkeypatch.setattr("nostr.client.Filter", DummyFilter) + monkeypatch.setattr("nostr.client.Tag", DummyTag) + monkeypatch.setattr("nostr.client.Timestamp", DummyTimestamp) + monkeypatch.setattr("nostr.client.EventId", DummyEventId) + from nostr.backup_models import KIND_DELTA as KD + + monkeypatch.setattr("nostr.client.KIND_DELTA", KD, raising=False) + monkeypatch.setattr(NostrClient, "initialize_client_pool", lambda self: None) + + enc_mgr = EncryptionManager(Fernet.generate_key(), tmp_path) + + class DummyKeys: + def private_key_hex(self): + return "1" * 64 + + def public_key_hex(self): + return "2" * 64 + + class DummyKeyManager: + def __init__(self, *a, **k): + self.keys = DummyKeys() + + with pytest.MonkeyPatch().context() as mp: + mp.setattr("nostr.client.KeyManager", DummyKeyManager) + mp.setattr(enc_mgr, "decrypt_parent_seed", lambda: TEST_SEED) + client = NostrClient(enc_mgr, "fp") + return client, relay diff --git a/src/tests/test_nostr_dummy_client.py b/src/tests/test_nostr_dummy_client.py new file mode 100644 index 0000000..cd1ab1a --- /dev/null +++ b/src/tests/test_nostr_dummy_client.py @@ -0,0 +1,50 @@ +import asyncio +import gzip +import math + +from helpers import create_vault, dummy_nostr_client +from password_manager.entry_management import EntryManager +from nostr.client import prepare_snapshot + + +def test_manifest_generation(tmp_path): + vault, enc_mgr = create_vault(tmp_path) + entry_mgr = EntryManager(vault, tmp_path) + entry_mgr.add_entry("example.com", 12) + entry_mgr.add_entry("test.com", 12) + encrypted = vault.get_encrypted_index() + assert encrypted + manifest, chunks = prepare_snapshot(encrypted, 100) + compressed = gzip.compress(encrypted) + expected = math.ceil(len(compressed) / 100) + assert len(chunks) == expected + assert len(manifest.chunks) == expected + for meta in manifest.chunks: + assert meta.id + assert meta.hash + + +def test_retrieve_multi_chunk_snapshot(dummy_nostr_client): + import os + + client, relay = dummy_nostr_client + data = os.urandom(120000) + manifest = asyncio.run(client.publish_snapshot(data, limit=50000)) + assert len(manifest.chunks) > 1 + fetched_manifest, chunk_bytes = asyncio.run(client.fetch_latest_snapshot()) + assert len(chunk_bytes) == len(manifest.chunks) + joined = b"".join(chunk_bytes) + assert gzip.decompress(joined) == data + + +def test_publish_and_fetch_deltas(dummy_nostr_client): + client, relay = dummy_nostr_client + base = b"base" + manifest = asyncio.run(client.publish_snapshot(base)) + manifest_id = relay.manifests[-1].id + d1 = b"d1" + d2 = b"d2" + asyncio.run(client.publish_delta(d1, manifest_id)) + asyncio.run(client.publish_delta(d2, manifest_id)) + deltas = asyncio.run(client.fetch_deltas_since(0)) + assert deltas == [d1, d2] From 8af1871f2d1aa8cf3cc86de6e7e46a6462f6f431 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 17:35:41 -0400 Subject: [PATCH 23/43] docs: update vault backup details --- README.md | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 0a808fc..ef6ed5e 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ ![SeedPass Logo](https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/refs/heads/main/logo/png/SeedPass-Logo-03.png) -**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 ensures that your passwords are safe and accessible across devices. The index for retrieving each password is securely stored on Nostr relays, allowing seamless password recovery on multiple devices without compromising security. +**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 splits it into 50 KB chunks. Each chunk is published as a parameterised replaceable event (`kind 30071`), with a manifest (`kind 30070`) describing the snapshot and deltas (`kind 30072`) capturing changes between snapshots. This allows secure password recovery across devices without exposing your data. --- **⚠️ Disclaimer** -This software was not developed by an experienced security expert and should be used with caution. There may be bugs and missing features. For instance, the maximum size of the index before the Nostr backup starts to have problems is unknown. Additionally, the security of the program's memory management and logs has not been evaluated and may leak sensitive information. +This software was not developed by an experienced security expert and should be used with caution. There may be bugs and missing features. Each vault chunk is limited to 50 KB and SeedPass periodically publishes a new snapshot to keep accumulated deltas small. The security of the program's memory management and logs has not been evaluated and may leak sensitive information. --- ### Supported OS @@ -40,6 +40,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - **Deterministic Password Generation:** Utilize BIP-85 for generating deterministic and secure passwords. - **Encrypted Storage:** All seeds, login passwords, and sensitive index data are encrypted locally. - **Nostr Integration:** Post and retrieve your encrypted password index to/from the Nostr network. +- **Chunked Snapshots:** Encrypted vaults are compressed and split into 50 KB chunks published as `kind 30071` events with a `kind 30070` manifest and `kind 30072` deltas. - **Checksum Verification:** Ensure the integrity of the script with checksum verification. - **Multiple Seed Profiles:** Manage separate seed profiles and switch between them seamlessly. - **Interactive TUI:** Navigate through menus to add, retrieve, and modify entries as well as configure Nostr settings. @@ -239,23 +240,15 @@ pytest -vv ### Exploring Nostr Index Size Limits -`test_nostr_index_size.py` now keeps adding entries until either the Nostr -relay or the SDK fails to publish or retrieve the encrypted index. This helps -discover the practical payload ceiling (the loop stops just below the 65 kB -event limit). Because each iteration pushes a larger blob to the relay, the -test is marked with both `desktop` and `network` and is not included in the -default test run. - -Set `NOSTR_TEST_DELAY` to throttle how many seconds the test waits between -publishes. The default is `5` seconds, but you can lengthen it to avoid rate -limits when exploring very large indexes. +`test_nostr_index_size.py` demonstrates how SeedPass rotates snapshots after too many delta events. +Each chunk is limited to 50 KB, so the test gradually grows the vault to observe +when a new snapshot is triggered. Use the `NOSTR_TEST_DELAY` environment +variable to control the delay between publishes when experimenting with large vaults. ```bash NOSTR_TEST_DELAY=10 pytest -vv src/tests/test_nostr_index_size.py -m "desktop and network" ``` -If you normally exclude those markers, remember to pass `-m "desktop and network"`. - ### Automatically Updating the Script Checksum SeedPass stores a SHA-256 checksum for the main program in `~/.seedpass/seedpass_script_checksum.txt`. @@ -292,7 +285,7 @@ Mutation testing is disabled in the GitHub workflow due to reliability issues an - **Backup the Settings PIN:** Your settings PIN is stored in the encrypted configuration file. Keep a copy of this file or remember the PIN, as losing it will require deleting the file and reconfiguring your relays. - **Protect Your Passwords:** Do not share your master password or seed phrases with anyone and ensure they are strong and unique. - **Checksum Verification:** Always verify the script's checksum to ensure its integrity and protect against unauthorized modifications. -- **Potential Bugs and Limitations:** Be aware that the software may contain bugs and lacks certain features. The maximum size of the password index before encountering issues with Nostr backups is unknown. Additionally, the security of memory management and logs has not been thoroughly evaluated and may pose risks of leaking sensitive information. +- **Potential Bugs and Limitations:** Be aware that the software may contain bugs and lacks certain features. Snapshot chunks are capped at 50 KB and the client rotates snapshots after enough delta events accumulate. The security of memory management and logs has not been thoroughly evaluated and may pose risks of leaking sensitive information. - **Multiple Seeds Management:** While managing multiple seeds adds flexibility, it also increases the responsibility to secure each seed and its associated password. - **No PBKDF2 Salt Required:** SeedPass deliberately omits an explicit PBKDF2 salt. Every password is derived from a unique 512-bit BIP-85 child seed, which already provides stronger per-password uniqueness than a conventional 128-bit salt. From 626d20b22f994e334c895147715e4688305e7119 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 2 Jul 2025 17:46:17 -0400 Subject: [PATCH 24/43] Update landing to match README snapshot info --- landing/index.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/landing/index.html b/landing/index.html index 80968f0..16e87b3 100644 --- a/landing/index.html +++ b/landing/index.html @@ -53,7 +53,7 @@

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 ensures that your passwords are safe and accessible across devices.

+

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.

Get Started
@@ -64,7 +64,7 @@