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** 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 @@
- Deterministic password generation using BIP-85
- Encrypted local storage for seeds and sensitive data
- - Nostr relay integration for secure backup and retrieval
+ - Nostr relay integration with parameterised replaceable events for chunked snapshots and deltas
- Seed/Fingerprint switching for managing multiple profiles
- Checksum verification to ensure script integrity
- Interactive TUI for managing entries and settings
@@ -91,7 +91,7 @@
Seed/Fingerprint Switching
SeedPass allows you to manage multiple seed profiles (fingerprints). You can switch between different seeds to compartmentalize your passwords.
Nostr Relay Integration
- By integrating with the Nostr network, SeedPass securely backs up your encrypted password index to Nostr relays, allowing you to retrieve your index on multiple devices without compromising security.
+ SeedPass publishes your encrypted vault to Nostr in 50 KB chunks using parameterised replaceable events. A manifest describes each snapshot while deltas record updates. When too many deltas accumulate, a new snapshot is rotated in automatically.
Checksum Verification
Built-in checksum verification ensures your SeedPass installation hasn't been tampered with.
Interactive TUI
@@ -209,7 +209,7 @@ Enter your choice (1-5):
Disclaimer
-
⚠️ 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.
+
⚠️ Disclaimer: This software was not developed by an experienced security expert and should be used with caution. There may be bugs and missing features. Additionally, the security of the program's memory management and logs has not been evaluated and may leak sensitive information.
From e1a5b9d4f1c413218caa7d95df246d80000b7d85 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Wed, 2 Jul 2025 18:21:05 -0400
Subject: [PATCH 25/43] docs: update nostr backup docs
---
docs/advanced_cli.md | 42 +++++++++++++++++++++---------------------
docs/json_entries.md | 2 +-
post-refactor-to-do.md | 2 +-
3 files changed, 23 insertions(+), 23 deletions(-)
diff --git a/docs/advanced_cli.md b/docs/advanced_cli.md
index 7faade5..983f4bd 100644
--- a/docs/advanced_cli.md
+++ b/docs/advanced_cli.md
@@ -25,17 +25,17 @@ The **Advanced CLI Commands** document provides an in-depth guide to the various
- [13. Disable Auto-Lock](#13-disable-auto-lock)
- [14. Generate a Strong Password](#14-generate-a-strong-password)
- [15. Verify Script Checksum](#15-verify-script-checksum)
- - [16. Post Encrypted Index to Nostr](#16-post-encrypted-index-to-nostr)
+ - [16. Post Encrypted Snapshots to Nostr](#16-post-encrypted-snapshots-to-nostr)
- [17. Retrieve from Nostr](#17-retrieve-from-nostr)
- [18. Display Nostr Public Key](#18-display-nostr-public-key)
- [19. Set Custom Nostr Relays](#19-set-custom-nostr-relays)
- [20. Enable "Secret" Mode](#20-enable-secret-mode)
- - [21. Batch Post Index Items to Nostr](#21-batch-post-index-items-to-nostr)
+ - [21. Batch Post Snapshot Deltas to Nostr](#21-batch-post-snapshot-deltas-to-nostr)
- [22. Show All Passwords](#22-show-all-passwords)
- [23. Add Notes to an Entry](#23-add-notes-to-an-entry)
- [24. Add Tags to an Entry](#24-add-tags-to-an-entry)
- [25. Search by Tag or Title](#25-search-by-tag-or-title)
- - [26. Automatically Post Index to Nostr After Edit](#26-automatically-post-index-to-nostr-after-edit)
+ - [26. Automatically Post Deltas to Nostr After Edit](#26-automatically-post-deltas-to-nostr-after-edit)
- [27. Initial Setup Prompt for Seed Generation/Import](#27-initial-setup-prompt-for-seed-generationimport)
3. [Notes on New CLI Commands](#notes-on-new-cli-commands)
@@ -62,17 +62,17 @@ The following table provides a quick reference to all available advanced CLI com
| Disable auto-lock | `autolock --disable` | `-DL` | `--auto-lock --disable` | `seedpass autolock --disable` |
| Generate a strong password | `generate` | `-G` | `--generate` | `seedpass generate --length 20` |
| Verify script checksum | `verify` | `-V` | `--verify` | `seedpass verify` |
-| Post encrypted index to Nostr | `post` | `-P` | `--post` | `seedpass post` |
-| Retrieve from Nostr | `get-nostr` | `-GN` | `--get-nostr` | `seedpass get-nostr` |
+| Post encrypted snapshots to Nostr | `post` | `-P` | `--post` | `seedpass post` |
+| Retrieve snapshots from Nostr | `get-nostr` | `-GN` | `--get-nostr` | `seedpass get-nostr` |
| Display Nostr public key | `show-pubkey` | `-K` | `--show-pubkey` | `seedpass show-pubkey` |
| Set Custom Nostr Relays | `set-relays` | `-SR` | `--set-relays` | `seedpass set-relays --add "wss://relay1.example.com" --add "wss://relay2.example.com"` |
| Enable "Secret" Mode | `set-secret` | `-SS` | `--set-secret` | `seedpass set-secret --enable` or `seedpass set-secret --disable` |
-| Batch Post Index Items to Nostr | `batch-post` | `-BP` | `--batch-post` | `seedpass batch-post --start 0 --end 9` or `seedpass batch-post --range 10-19` |
+| Batch Post Snapshot Deltas to Nostr | `batch-post` | `-BP` | `--batch-post` | `seedpass batch-post --start 0 --end 9` or `seedpass batch-post --range 10-19` |
| Show All Passwords | `show-all` | `-SA` | `--show-all` | `seedpass show-all` |
| Add Notes to an Entry | `add-notes` | `-AN` | `--add-notes` | `seedpass add-notes --index 3 --notes "This is a secured account"` |
| Add Tags to an Entry | `add-tags` | `-AT` | `--add-tags` | `seedpass add-tags --index 3 --tags "personal,finance"` |
| Search by Tag or Title | `search-by` | `-SB` | `--search-by` | `seedpass search-by --tag "work"` or `seedpass search-by --title "GitHub"` |
-| Automatically Post Index to Nostr After Edit | `auto-post` | `-AP` | `--auto-post` | `seedpass auto-post --enable` or `seedpass auto-post --disable` |
+| Automatically Post Deltas After Edit | `auto-post` | `-AP` | `--auto-post` | `seedpass auto-post --enable` or `seedpass auto-post --disable` |
| Initial Setup Prompt for Seed Generation/Import | `setup` | `-ST` | `--setup` | `seedpass setup` |
---
@@ -389,14 +389,14 @@ seedpass verify
---
-### 16. Post Encrypted Index to Nostr
+### 16. Post Encrypted Snapshots to Nostr
**Command:** `post`
**Short Flag:** `-P`
**Long Flag:** `--post`
**Description:**
-Posts the encrypted password index to the Nostr network, facilitating secure backups and synchronization across devices.
+Posts encrypted snapshot chunks of the index to the Nostr network, followed by compact delta events for subsequent changes. This approach enables reliable backups and efficient synchronization across devices.
**Usage Example:**
```bash
@@ -412,7 +412,7 @@ seedpass post
**Long Flag:** `--get-nostr`
**Description:**
-Retrieves the encrypted password index from the Nostr network, allowing users to restore their password data on a new device.
+Retrieves the encrypted snapshot chunks and any delta events from the Nostr network, allowing users to reconstruct the latest index on a new device.
**Usage Example:**
```bash
@@ -444,7 +444,7 @@ seedpass show-pubkey
**Long Flag:** `--set-relays`
**Description:**
-Allows users to specify custom Nostr relays for publishing their encrypted backup index, providing flexibility and control over data distribution.
+Allows users to specify custom Nostr relays for publishing their encrypted backup snapshots, providing flexibility and control over data distribution.
Relay URLs are stored in an encrypted configuration file located in `~/.seedpass//seedpass_config.json.enc` and loaded each time the Nostr client starts. New accounts use the following default relays until changed:
```
@@ -484,14 +484,14 @@ seedpass set-secret --disable
---
-### 21. Batch Post Index Items to Nostr
+### 21. Batch Post Snapshot Deltas to Nostr
**Command:** `batch-post`
**Short Flag:** `-BP`
**Long Flag:** `--batch-post`
**Description:**
-Posts a specified range of index items to the Nostr network in batches, ensuring efficient and manageable data transmission.
+Posts a specified range of snapshot delta events to the Nostr network in batches, ensuring efficient and manageable data transmission.
**Usage Examples:**
```bash
@@ -583,14 +583,14 @@ seedpass search-by --title "GitHub"
---
-### 26. Automatically Post Index to Nostr After Edit
+### 26. Automatically Post Deltas to Nostr After Edit
**Command:** `auto-post`
**Short Flag:** `-AP`
**Long Flag:** `--auto-post`
**Description:**
-Enables or disables the automatic posting of the password index to the Nostr network whenever an edit occurs, ensuring real-time backups.
+Enables or disables the automatic posting of snapshot delta events to the Nostr network whenever an edit occurs, ensuring real-time backups.
**Usage Examples:**
```bash
@@ -621,7 +621,7 @@ seedpass setup
**Features to Implement:**
- **Seed Choice Prompt:** Asks users whether they want to generate a new seed or import an existing one.
- **Encryption of Seed:** Uses the user-selected password to encrypt the seed, whether generated or imported.
-- **Profile Creation:** Upon first login, automatically generates a profile and checks for existing index data notes that can be pulled and decrypted.
+- **Profile Creation:** Upon first login, automatically generates a profile and checks for existing snapshot data that can be pulled and decrypted.
---
@@ -782,8 +782,8 @@ seedpass fingerprint rename A1B2C3D4 PersonalProfile
## Notes on New CLI Commands
-1. **Automatically Post Index to Nostr After Edit (`auto-post`):**
- - **Purpose:** Enables or disables the automatic posting of the index to Nostr whenever an edit occurs.
+1. **Automatically Post Deltas to Nostr After Edit (`auto-post`):**
+ - **Purpose:** Enables or disables the automatic posting of snapshot deltas to Nostr whenever an edit occurs.
- **Usage Examples:**
- Enable auto-post: `seedpass auto-post --enable`
- Disable auto-post: `seedpass auto-post --disable`
@@ -793,7 +793,7 @@ seedpass fingerprint rename A1B2C3D4 PersonalProfile
- **Features to Implement:**
- **Seed Choice Prompt:** Ask users whether they want to generate a new seed or import an existing one.
- **Encryption of Seed:** Use the user-selected password to encrypt the seed, whether generated or imported.
- - **Profile Creation:** Upon first login, automatically generate a profile and check for existing index data notes that can be pulled and decrypted.
+ - **Profile Creation:** Upon first login, automatically generate a profile and check for existing snapshot data that can be pulled and decrypted.
- **Usage Example:** `seedpass setup`
3. **Advanced CLI Enhancements:**
@@ -807,8 +807,8 @@ seedpass fingerprint rename A1B2C3D4 PersonalProfile
- **Description:** When running `seedpass setup`, prompts users to either enter an existing seed or generate a new one, followed by password creation for encryption.
- **Usage Example:** `seedpass setup`
- - **Automatic Profile Generation and Index Retrieval:**
- - **Description:** During the initial setup or first login, generates a profile and attempts to retrieve and decrypt any existing index data from Nostr.
+ - **Automatic Profile Generation and Snapshot Retrieval:**
+ - **Description:** During the initial setup or first login, generates a profile and attempts to retrieve and decrypt any existing snapshots and deltas from Nostr.
- **Usage Example:** `seedpass setup` (handles internally)
---
diff --git a/docs/json_entries.md b/docs/json_entries.md
index d2bf840..295ce48 100644
--- a/docs/json_entries.md
+++ b/docs/json_entries.md
@@ -29,7 +29,7 @@
## Introduction
-**SeedPass** is a secure password generator and manager leveraging **Bitcoin's BIP-85 standard** and integrating with the **Nostr network** for decentralized synchronization. To enhance modularity, scalability, and security, SeedPass now manages each password or data entry as a separate JSON file within a **Fingerprint-Based Backup and Local Storage** system. This document outlines the new entry management system, ensuring that new `kind` types can be added seamlessly without disrupting existing functionalities.
+**SeedPass** is a secure password generator and manager leveraging **Bitcoin's BIP-85 standard** and integrating with the **Nostr network** for decentralized synchronization. Instead of pushing one large index file, SeedPass posts **snapshot chunks** of the index followed by lightweight **delta events** whenever changes occur. This chunked approach improves reliability and keeps bandwidth usage minimal. To enhance modularity, scalability, and security, SeedPass now manages each password or data entry as a separate JSON file within a **Fingerprint-Based Backup and Local Storage** system. This document outlines the new entry management system, ensuring that new `kind` types can be added seamlessly without disrupting existing functionalities.
---
diff --git a/post-refactor-to-do.md b/post-refactor-to-do.md
index 06383c8..62a038a 100644
--- a/post-refactor-to-do.md
+++ b/post-refactor-to-do.md
@@ -14,7 +14,7 @@
| ------------ | ------------------------------------------------------------------------------ |
| **Core API** | `seedpass.api` – headless services consumed by CLI / GUI |
| **Profile** | A fingerprint‑scoped vault: parent‑seed + hashed pw + entries |
-| **Entry** | One encrypted JSON blob on disk *and* one replaceable Nostr event (kind 31111) |
+| **Entry** | One encrypted JSON blob on disk plus Nostr snapshot chunks and delta events |
| **GUI MVP** | Desktop app built with PySide 6 announced in the v2 roadmap |
---
From d2832db194c55b02aa6839f113e90622639600a2 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Wed, 2 Jul 2025 18:48:15 -0400
Subject: [PATCH 26/43] Remove password-based encryption modes
---
src/main.py | 9 +-
src/password_manager/manager.py | 54 ++-------
src/password_manager/portable_backup.py | 30 ++---
src/tests/helpers.py | 4 +-
src/tests/test_cli_encryption_mode.py | 20 ----
.../test_cli_portable_backup_commands.py | 9 +-
src/tests/test_concurrency_stress.py | 8 +-
src/tests/test_encryption_mode_change.py | 57 ---------
src/tests/test_encryption_mode_migration.py | 92 --------------
src/tests/test_index_import_export.py | 22 +---
src/tests/test_key_derivation.py | 23 +---
src/tests/test_password_change.py | 2 +-
.../test_password_unlock_after_change.py | 4 +-
src/tests/test_portable_backup.py | 112 ++++--------------
src/utils/key_derivation.py | 37 +-----
15 files changed, 56 insertions(+), 427 deletions(-)
delete mode 100644 src/tests/test_encryption_mode_change.py
delete mode 100644 src/tests/test_encryption_mode_migration.py
diff --git a/src/main.py b/src/main.py
index d1bec58..ef6a590 100644
--- a/src/main.py
+++ b/src/main.py
@@ -15,7 +15,6 @@ from termcolor import colored
import traceback
from password_manager.manager import PasswordManager
-from password_manager.portable_backup import PortableMode
from nostr.client import NostrClient
from constants import INACTIVITY_TIMEOUT
from utils.key_derivation import EncryptionMode
@@ -630,11 +629,6 @@ if __name__ == "__main__":
)
exp = sub.add_parser("export")
- exp.add_argument(
- "--mode",
- choices=[m.value for m in PortableMode],
- default=PortableMode.SEED_ONLY.value,
- )
exp.add_argument("--file")
imp = sub.add_parser("import")
@@ -662,8 +656,7 @@ if __name__ == "__main__":
sys.exit(1)
if args.command == "export":
- mode = PortableMode(args.mode)
- password_manager.handle_export_database(mode, Path(args.file))
+ password_manager.handle_export_database(Path(args.file))
sys.exit(0)
elif args.command == "import":
password_manager.handle_import_database(Path(args.file))
diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py
index 59ad46d..9233cd5 100644
--- a/src/password_manager/manager.py
+++ b/src/password_manager/manager.py
@@ -24,11 +24,7 @@ from password_manager.entry_management import EntryManager
from password_manager.password_generation import PasswordGenerator
from password_manager.backup import BackupManager
from password_manager.vault import Vault
-from password_manager.portable_backup import (
- export_backup,
- import_backup,
- PortableMode,
-)
+from password_manager.portable_backup import export_backup, import_backup
from utils.key_derivation import (
derive_key_from_parent_seed,
derive_key_from_password,
@@ -125,22 +121,7 @@ class PasswordManager:
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
+ # Only seed-only mode is supported
return EncryptionMode.SEED_ONLY
def lock_vault(self) -> None:
@@ -309,11 +290,7 @@ class PasswordManager:
sys.exit(1)
return False
- key = derive_index_key(
- self.parent_seed,
- password,
- self.encryption_mode,
- )
+ key = derive_index_key(self.parent_seed)
self.encryption_manager = EncryptionManager(key, fingerprint_dir)
self.vault = Vault(self.encryption_manager, fingerprint_dir)
@@ -566,11 +543,7 @@ class PasswordManager:
# Initialize EncryptionManager with key and fingerprint_dir
password = prompt_for_password()
- index_key = derive_index_key(
- parent_seed,
- password,
- self.encryption_mode,
- )
+ index_key = derive_index_key(parent_seed)
seed_key = derive_key_from_password(password)
self.encryption_manager = EncryptionManager(index_key, fingerprint_dir)
@@ -707,11 +680,7 @@ class PasswordManager:
# Prompt for password
password = prompt_for_password()
- index_key = derive_index_key(
- seed,
- password,
- self.encryption_mode,
- )
+ index_key = derive_index_key(seed)
seed_key = derive_key_from_password(password)
self.encryption_manager = EncryptionManager(index_key, fingerprint_dir)
@@ -1225,7 +1194,6 @@ class PasswordManager:
def handle_export_database(
self,
- mode: "PortableMode" = PortableMode.SEED_ONLY,
dest: Path | None = None,
) -> Path | None:
"""Export the current database to an encrypted portable file."""
@@ -1233,7 +1201,6 @@ class PasswordManager:
path = export_backup(
self.vault,
self.backup_manager,
- mode,
dest,
parent_seed=self.parent_seed,
)
@@ -1438,14 +1405,7 @@ class PasswordManager:
# Create a new encryption manager with the new password
mode = getattr(self, "encryption_mode", DEFAULT_ENCRYPTION_MODE)
- try:
- new_key = derive_index_key(
- self.parent_seed,
- new_password,
- mode,
- )
- except Exception:
- new_key = derive_key_from_password(new_password)
+ new_key = derive_index_key(self.parent_seed)
seed_key = derive_key_from_password(new_password)
seed_mgr = EncryptionManager(seed_key, self.fingerprint_dir)
@@ -1497,7 +1457,7 @@ class PasswordManager:
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_key = derive_index_key(self.parent_seed)
new_mgr = EncryptionManager(new_key, self.fingerprint_dir)
self.vault.set_encryption_manager(new_mgr)
diff --git a/src/password_manager/portable_backup.py b/src/password_manager/portable_backup.py
index 1c4a9eb..48ad688 100644
--- a/src/password_manager/portable_backup.py
+++ b/src/password_manager/portable_backup.py
@@ -20,7 +20,6 @@ from utils.key_derivation import (
EncryptionMode,
DEFAULT_ENCRYPTION_MODE,
)
-from utils.password_prompt import prompt_existing_password
from password_manager.encryption import EncryptionManager
from utils.checksum import json_checksum, canonical_json_dumps
@@ -34,25 +33,17 @@ class PortableMode(Enum):
"""Encryption mode for portable exports."""
SEED_ONLY = EncryptionMode.SEED_ONLY.value
- SEED_PLUS_PW = EncryptionMode.SEED_PLUS_PW.value
- PW_ONLY = EncryptionMode.PW_ONLY.value
-def _derive_export_key(
- seed: str,
- mode: PortableMode,
- password: str | None = None,
-) -> bytes:
+def _derive_export_key(seed: str) -> bytes:
"""Derive the Fernet key for the export payload."""
- enc_mode = EncryptionMode(mode.value)
- return derive_index_key(seed, password, enc_mode)
+ return derive_index_key(seed)
def export_backup(
vault: Vault,
backup_manager: BackupManager,
- mode: PortableMode = PortableMode.SEED_ONLY,
dest_path: Path | None = None,
*,
publish: bool = False,
@@ -72,11 +63,7 @@ def export_backup(
if parent_seed is not None
else vault.encryption_manager.decrypt_parent_seed()
)
- password = None
- if mode in (PortableMode.SEED_PLUS_PW, PortableMode.PW_ONLY):
- password = prompt_existing_password("Enter your master password: ")
-
- key = _derive_export_key(seed, mode, password)
+ key = _derive_export_key(seed)
enc_mgr = EncryptionManager(key, vault.fingerprint_dir)
canonical = canonical_json_dumps(index_data)
@@ -87,7 +74,7 @@ def export_backup(
"format_version": FORMAT_VERSION,
"created_at": int(time.time()),
"fingerprint": vault.fingerprint_dir.name,
- "encryption_mode": mode.value,
+ "encryption_mode": PortableMode.SEED_ONLY.value,
"cipher": "fernet",
"checksum": checksum,
"payload": base64.b64encode(payload_bytes).decode("utf-8"),
@@ -127,7 +114,8 @@ def import_backup(
if wrapper.get("format_version") != FORMAT_VERSION:
raise ValueError("Unsupported backup format")
- mode = PortableMode(wrapper.get("encryption_mode", PortableMode.SEED_ONLY.value))
+ if wrapper.get("encryption_mode") != PortableMode.SEED_ONLY.value:
+ raise ValueError("Unsupported encryption mode")
payload = base64.b64decode(wrapper["payload"])
seed = (
@@ -135,11 +123,7 @@ def import_backup(
if parent_seed is not None
else vault.encryption_manager.decrypt_parent_seed()
)
- password = None
- if mode in (PortableMode.SEED_PLUS_PW, PortableMode.PW_ONLY):
- password = prompt_existing_password("Enter your master password: ")
-
- key = _derive_export_key(seed, mode, password)
+ key = _derive_export_key(seed)
enc_mgr = EncryptionManager(key, vault.fingerprint_dir)
index_bytes = enc_mgr.decrypt_data(payload)
index = json.loads(index_bytes.decode("utf-8"))
diff --git a/src/tests/helpers.py b/src/tests/helpers.py
index b6d61ec..914968a 100644
--- a/src/tests/helpers.py
+++ b/src/tests/helpers.py
@@ -8,7 +8,6 @@ from password_manager.encryption import EncryptionManager
from utils.key_derivation import (
derive_index_key,
derive_key_from_password,
- EncryptionMode,
)
TEST_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
@@ -19,14 +18,13 @@ def create_vault(
dir_path: Path,
seed: str = TEST_SEED,
password: str = TEST_PASSWORD,
- mode: EncryptionMode = EncryptionMode.SEED_ONLY,
) -> tuple[Vault, EncryptionManager]:
"""Create a Vault initialized for tests."""
seed_key = derive_key_from_password(password)
seed_mgr = EncryptionManager(seed_key, dir_path)
seed_mgr.encrypt_parent_seed(seed)
- index_key = derive_index_key(seed, password, mode)
+ index_key = derive_index_key(seed)
enc_mgr = EncryptionManager(index_key, dir_path)
vault = Vault(enc_mgr, dir_path)
return vault, enc_mgr
diff --git a/src/tests/test_cli_encryption_mode.py b/src/tests/test_cli_encryption_mode.py
index 7a54809..d5f235f 100644
--- a/src/tests/test_cli_encryption_mode.py
+++ b/src/tests/test_cli_encryption_mode.py
@@ -33,23 +33,3 @@ def _get_mode(monkeypatch, args=None, cfg=None):
def test_default_mode_is_seed_only(monkeypatch):
mode = _get_mode(monkeypatch)
assert mode is EncryptionMode.SEED_ONLY
-
-
-def test_cli_flag_overrides_config(monkeypatch):
- cfg = {"encryption_mode": EncryptionMode.PW_ONLY.value}
- mode = _get_mode(monkeypatch, ["--encryption-mode", "seed+pw"], cfg)
- assert mode is EncryptionMode.SEED_PLUS_PW
-
-
-def test_pw_only_emits_warning(monkeypatch, capsys):
- pm = PasswordManager.__new__(PasswordManager)
- pm.encryption_mode = EncryptionMode.SEED_ONLY
- pm.fingerprint_manager = object()
- pm.setup_existing_seed = lambda: None
- pm.generate_new_seed = lambda: None
- inputs = iter(["3", "1"])
- monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
- pm.handle_new_seed_setup()
- out = capsys.readouterr().out
- assert "Password-only encryption is less secure" in out
- assert pm.encryption_mode is EncryptionMode.PW_ONLY
diff --git a/src/tests/test_cli_portable_backup_commands.py b/src/tests/test_cli_portable_backup_commands.py
index fa6c86c..3b13dfb 100644
--- a/src/tests/test_cli_portable_backup_commands.py
+++ b/src/tests/test_cli_portable_backup_commands.py
@@ -7,7 +7,6 @@ import pytest
sys.path.append(str(Path(__file__).resolve().parents[1]))
import main
-from password_manager.portable_backup import PortableMode
from password_manager.manager import PasswordManager
@@ -19,8 +18,8 @@ def _run(argv, monkeypatch):
def fake_init(self, encryption_mode):
called["init"] = True
- def fake_export(self, mode, dest):
- called["export"] = (mode, Path(dest))
+ def fake_export(self, dest):
+ called["export"] = Path(dest)
def fake_import(self, src):
called["import"] = Path(src)
@@ -36,8 +35,8 @@ def _run(argv, monkeypatch):
def test_export_command_invokes_handler(monkeypatch):
- called = _run(["export", "--mode", "pw-only", "--file", "out.json"], monkeypatch)
- assert called["export"] == (PortableMode.PW_ONLY, Path("out.json"))
+ called = _run(["export", "--file", "out.json"], monkeypatch)
+ assert called["export"] == Path("out.json")
assert "import" not in called
diff --git a/src/tests/test_concurrency_stress.py b/src/tests/test_concurrency_stress.py
index 0e16893..109e337 100644
--- a/src/tests/test_concurrency_stress.py
+++ b/src/tests/test_concurrency_stress.py
@@ -9,11 +9,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.encryption import EncryptionManager
from password_manager.vault import Vault
from password_manager.backup import BackupManager
-from utils.key_derivation import (
- derive_index_key,
- derive_key_from_password,
- EncryptionMode,
-)
+from utils.key_derivation import derive_index_key, derive_key_from_password
def _writer(index_key: bytes, dir_path: Path, loops: int, out: Queue) -> None:
@@ -50,7 +46,7 @@ def _backup(dir_path: Path, loops: int, out: Queue) -> None:
@pytest.mark.parametrize("loops", [5, pytest.param(20, marks=pytest.mark.stress)])
@pytest.mark.parametrize("_", range(3))
def test_concurrency_stress(tmp_path: Path, loops: int, _):
- index_key = derive_index_key(TEST_SEED, TEST_PASSWORD, EncryptionMode.SEED_ONLY)
+ index_key = derive_index_key(TEST_SEED)
seed_key = derive_key_from_password(TEST_PASSWORD)
EncryptionManager(seed_key, tmp_path).encrypt_parent_seed(TEST_SEED)
enc = EncryptionManager(index_key, tmp_path)
diff --git a/src/tests/test_encryption_mode_change.py b/src/tests/test_encryption_mode_change.py
deleted file mode 100644
index e91ec0a..0000000
--- a/src/tests/test_encryption_mode_change.py
+++ /dev/null
@@ -1,57 +0,0 @@
-import sys
-from pathlib import Path
-from tempfile import TemporaryDirectory
-from types import SimpleNamespace
-from unittest.mock import patch, AsyncMock
-
-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
- mock.publish_snapshot = AsyncMock(return_value=None)
- pm.nostr_client = mock
- pm.change_encryption_mode(EncryptionMode.SEED_PLUS_PW)
- 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
- loaded = vault.load_index()
- assert loaded["passwords"] == {}
- cfg = cfg_mgr.load_config(require_pin=False)
- assert cfg["encryption_mode"] == EncryptionMode.SEED_PLUS_PW.value
diff --git a/src/tests/test_encryption_mode_migration.py b/src/tests/test_encryption_mode_migration.py
deleted file mode 100644
index 1970585..0000000
--- a/src/tests/test_encryption_mode_migration.py
+++ /dev/null
@@ -1,92 +0,0 @@
-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_snapshot=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_snapshot=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_index_import_export.py b/src/tests/test_index_import_export.py
index b9d7fbb..87ec285 100644
--- a/src/tests/test_index_import_export.py
+++ b/src/tests/test_index_import_export.py
@@ -9,38 +9,26 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.encryption import EncryptionManager
from password_manager.vault import Vault
-from utils.key_derivation import (
- derive_index_key,
- derive_key_from_password,
- EncryptionMode,
-)
+from utils.key_derivation import derive_index_key, derive_key_from_password
SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
PASSWORD = "passw0rd"
-def setup_vault(tmp: Path, mode: EncryptionMode) -> Vault:
+def setup_vault(tmp: Path) -> Vault:
seed_key = derive_key_from_password(PASSWORD)
seed_mgr = EncryptionManager(seed_key, tmp)
seed_mgr.encrypt_parent_seed(SEED)
- key = derive_index_key(SEED, PASSWORD, mode)
+ key = derive_index_key(SEED)
enc_mgr = EncryptionManager(key, tmp)
return Vault(enc_mgr, tmp)
-@pytest.mark.parametrize(
- "mode",
- [
- EncryptionMode.SEED_ONLY,
- EncryptionMode.SEED_PLUS_PW,
- EncryptionMode.PW_ONLY,
- ],
-)
-def test_index_export_import_round_trip(mode):
+def test_index_export_import_round_trip():
with TemporaryDirectory() as td:
tmp = Path(td)
- vault = setup_vault(tmp, mode)
+ vault = setup_vault(tmp)
original = {"passwords": {"0": {"website": "example"}}}
vault.save_index(original)
diff --git a/src/tests/test_key_derivation.py b/src/tests/test_key_derivation.py
index 06b5f6a..a1ea90f 100644
--- a/src/tests/test_key_derivation.py
+++ b/src/tests/test_key_derivation.py
@@ -3,9 +3,7 @@ import pytest
from utils.key_derivation import (
derive_key_from_password,
derive_index_key_seed_only,
- derive_index_key_seed_plus_pw,
derive_index_key,
- EncryptionMode,
)
@@ -32,23 +30,6 @@ def test_seed_only_key_deterministic():
assert len(k1) == 44
-def test_seed_plus_pw_differs_from_seed_only():
+def test_derive_index_key_seed_only():
seed = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
- pw = "hunter2"
- k1 = derive_index_key_seed_only(seed)
- k2 = derive_index_key_seed_plus_pw(seed, pw)
- assert k1 != k2
-
-
-def test_derive_index_key_modes():
- seed = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
- pw = "hunter2"
- assert derive_index_key(
- seed, pw, EncryptionMode.SEED_ONLY
- ) == derive_index_key_seed_only(seed)
- assert derive_index_key(
- seed, pw, EncryptionMode.SEED_PLUS_PW
- ) == derive_index_key_seed_plus_pw(seed, pw)
- assert derive_index_key(
- seed, pw, EncryptionMode.PW_ONLY
- ) == derive_key_from_password(pw)
+ assert derive_index_key(seed) == derive_index_key_seed_only(seed)
diff --git a/src/tests/test_password_change.py b/src/tests/test_password_change.py
index 85ee9a6..8a4e4ea 100644
--- a/src/tests/test_password_change.py
+++ b/src/tests/test_password_change.py
@@ -29,7 +29,7 @@ def test_change_password_triggers_nostr_backup(monkeypatch):
pm.password_generator = SimpleNamespace(encryption_manager=enc_mgr)
pm.fingerprint_dir = fp
pm.current_fingerprint = "fp"
- pm.parent_seed = "seed"
+ pm.parent_seed = TEST_SEED
pm.store_hashed_password = lambda pw: None
pm.verify_password = lambda pw: True
diff --git a/src/tests/test_password_unlock_after_change.py b/src/tests/test_password_unlock_after_change.py
index 17e21b5..38e11e2 100644
--- a/src/tests/test_password_unlock_after_change.py
+++ b/src/tests/test_password_unlock_after_change.py
@@ -24,7 +24,7 @@ def test_password_change_and_unlock(monkeypatch):
new_pw = "newpw"
# initial encryption setup
- index_key = derive_index_key(SEED, old_pw, EncryptionMode.SEED_PLUS_PW)
+ index_key = derive_index_key(SEED)
seed_key = derive_key_from_password(old_pw)
enc_mgr = EncryptionManager(index_key, fp)
seed_mgr = EncryptionManager(seed_key, fp)
@@ -45,7 +45,7 @@ def test_password_change_and_unlock(monkeypatch):
seed_mgr.encrypt_parent_seed(SEED)
pm = PasswordManager.__new__(PasswordManager)
- pm.encryption_mode = EncryptionMode.SEED_PLUS_PW
+ pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.entry_manager = entry_mgr
pm.config_manager = cfg_mgr
diff --git a/src/tests/test_portable_backup.py b/src/tests/test_portable_backup.py
index 16d1e3c..674e841 100644
--- a/src/tests/test_portable_backup.py
+++ b/src/tests/test_portable_backup.py
@@ -11,57 +11,39 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.encryption import EncryptionManager
from password_manager.vault import Vault
from password_manager.backup import BackupManager
-from password_manager.portable_backup import (
- PortableMode,
- export_backup,
- import_backup,
-)
-from utils.key_derivation import (
- derive_index_key,
- derive_key_from_password,
- EncryptionMode,
-)
+from password_manager.portable_backup import export_backup, import_backup
+from utils.key_derivation import derive_index_key, derive_key_from_password
SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
PASSWORD = "passw0rd"
-def setup_vault(tmp: Path, mode: EncryptionMode = EncryptionMode.SEED_ONLY):
+def setup_vault(tmp: Path):
seed_key = derive_key_from_password(PASSWORD)
seed_mgr = EncryptionManager(seed_key, tmp)
seed_mgr.encrypt_parent_seed(SEED)
- index_key = derive_index_key(SEED, PASSWORD, mode)
+ index_key = derive_index_key(SEED)
enc_mgr = EncryptionManager(index_key, tmp)
vault = Vault(enc_mgr, tmp)
backup = BackupManager(tmp)
return vault, backup
-def test_round_trip_across_modes(monkeypatch):
- for pmode in [
- PortableMode.SEED_ONLY,
- PortableMode.SEED_PLUS_PW,
- PortableMode.PW_ONLY,
- ]:
- with TemporaryDirectory() as td:
- tmp = Path(td)
- vault, backup = setup_vault(tmp)
- data = {"pw": 1}
- vault.save_index(data)
+def test_round_trip(monkeypatch):
+ with TemporaryDirectory() as td:
+ tmp = Path(td)
+ vault, backup = setup_vault(tmp)
+ data = {"pw": 1}
+ vault.save_index(data)
- monkeypatch.setattr(
- "password_manager.portable_backup.prompt_existing_password",
- lambda *_a, **_k: PASSWORD,
- )
+ path = export_backup(vault, backup, parent_seed=SEED)
+ assert path.exists()
- path = export_backup(vault, backup, pmode, parent_seed=SEED)
- assert path.exists()
-
- vault.save_index({"pw": 0})
- import_backup(vault, backup, path, parent_seed=SEED)
- assert vault.load_index()["pw"] == data["pw"]
+ vault.save_index({"pw": 0})
+ import_backup(vault, backup, path, parent_seed=SEED)
+ assert vault.load_index()["pw"] == data["pw"]
from cryptography.fernet import InvalidToken
@@ -73,11 +55,7 @@ def test_corruption_detection(monkeypatch):
vault, backup = setup_vault(tmp)
vault.save_index({"a": 1})
- monkeypatch.setattr(
- "password_manager.portable_backup.prompt_existing_password",
- lambda *_a, **_k: PASSWORD,
- )
- path = export_backup(vault, backup, PortableMode.SEED_ONLY, parent_seed=SEED)
+ path = export_backup(vault, backup, parent_seed=SEED)
content = json.loads(path.read_text())
payload = base64.b64decode(content["payload"])
@@ -89,42 +67,13 @@ def test_corruption_detection(monkeypatch):
import_backup(vault, backup, path, parent_seed=SEED)
-def test_incorrect_credentials(monkeypatch):
- with TemporaryDirectory() as td:
- tmp = Path(td)
- vault, backup = setup_vault(tmp)
- vault.save_index({"a": 2})
-
- monkeypatch.setattr(
- "password_manager.portable_backup.prompt_existing_password",
- lambda *_a, **_k: PASSWORD,
- )
- path = export_backup(
- vault,
- backup,
- PortableMode.SEED_PLUS_PW,
- parent_seed=SEED,
- )
-
- monkeypatch.setattr(
- "password_manager.portable_backup.prompt_existing_password",
- lambda *_a, **_k: "wrong",
- )
- with pytest.raises(Exception):
- import_backup(vault, backup, path, parent_seed=SEED)
-
-
def test_import_over_existing(monkeypatch):
with TemporaryDirectory() as td:
tmp = Path(td)
vault, backup = setup_vault(tmp)
vault.save_index({"v": 1})
- monkeypatch.setattr(
- "password_manager.portable_backup.prompt_existing_password",
- lambda *_a, **_k: PASSWORD,
- )
- path = export_backup(vault, backup, PortableMode.SEED_ONLY, parent_seed=SEED)
+ path = export_backup(vault, backup, parent_seed=SEED)
vault.save_index({"v": 2})
import_backup(vault, backup, path, parent_seed=SEED)
@@ -138,21 +87,11 @@ def test_checksum_mismatch_detection(monkeypatch):
vault, backup = setup_vault(tmp)
vault.save_index({"a": 1})
- monkeypatch.setattr(
- "password_manager.portable_backup.prompt_existing_password",
- lambda *_a, **_k: PASSWORD,
- )
-
- path = export_backup(
- vault,
- backup,
- PortableMode.SEED_ONLY,
- parent_seed=SEED,
- )
+ path = export_backup(vault, backup, parent_seed=SEED)
wrapper = json.loads(path.read_text())
payload = base64.b64decode(wrapper["payload"])
- key = derive_index_key(SEED, PASSWORD, EncryptionMode.SEED_ONLY)
+ key = derive_index_key(SEED)
enc_mgr = EncryptionManager(key, tmp)
data = json.loads(enc_mgr.decrypt_data(payload).decode())
data["a"] = 2
@@ -165,23 +104,14 @@ def test_checksum_mismatch_detection(monkeypatch):
import_backup(vault, backup, path, parent_seed=SEED)
-@pytest.mark.parametrize(
- "pmode",
- [PortableMode.SEED_ONLY, PortableMode.SEED_PLUS_PW],
-)
-def test_export_import_seed_encrypted_with_different_key(monkeypatch, pmode):
+def test_export_import_seed_encrypted_with_different_key(monkeypatch):
"""Ensure backup round trip works when seed is encrypted with another key."""
with TemporaryDirectory() as td:
tmp = Path(td)
vault, backup = setup_vault(tmp)
vault.save_index({"v": 123})
- monkeypatch.setattr(
- "password_manager.portable_backup.prompt_existing_password",
- lambda *_a, **_k: PASSWORD,
- )
-
- path = export_backup(vault, backup, pmode, parent_seed=SEED)
+ path = export_backup(vault, backup, parent_seed=SEED)
vault.save_index({"v": 0})
import_backup(vault, backup, path, parent_seed=SEED)
assert vault.load_index()["v"] == 123
diff --git a/src/utils/key_derivation.py b/src/utils/key_derivation.py
index 0f9d6ff..0733424 100644
--- a/src/utils/key_derivation.py
+++ b/src/utils/key_derivation.py
@@ -41,8 +41,6 @@ class EncryptionMode(Enum):
"""Supported key derivation modes for database encryption."""
SEED_ONLY = "seed-only"
- SEED_PLUS_PW = "seed+pw"
- PW_ONLY = "pw-only"
DEFAULT_ENCRYPTION_MODE = EncryptionMode.SEED_ONLY
@@ -193,35 +191,6 @@ def derive_index_key_seed_only(seed: str) -> bytes:
return base64.urlsafe_b64encode(key)
-def derive_index_key_seed_plus_pw(seed: str, password: str) -> bytes:
- """Derive the index key from seed and password combined."""
- seed_bytes = Bip39SeedGenerator(seed).Generate()
- pw_bytes = unicodedata.normalize("NFKD", password).encode("utf-8")
- hkdf = HKDF(
- algorithm=hashes.SHA256(),
- length=32,
- salt=None,
- info=b"password-db",
- backend=default_backend(),
- )
- key = hkdf.derive(seed_bytes + b"|" + pw_bytes)
- return base64.urlsafe_b64encode(key)
-
-
-def derive_index_key(
- seed: str,
- password: Optional[str] = None,
- mode: EncryptionMode = DEFAULT_ENCRYPTION_MODE,
-) -> bytes:
- """Derive the index encryption key based on the selected mode."""
- if mode == EncryptionMode.SEED_ONLY:
- return derive_index_key_seed_only(seed)
- if mode == EncryptionMode.SEED_PLUS_PW:
- if password is None:
- raise ValueError("Password required for seed+pw mode")
- return derive_index_key_seed_plus_pw(seed, password)
- if mode == EncryptionMode.PW_ONLY:
- if password is None:
- raise ValueError("Password required for pw-only mode")
- return derive_key_from_password(password)
- raise ValueError(f"Unsupported encryption mode: {mode}")
+def derive_index_key(seed: str) -> bytes:
+ """Derive the index encryption key."""
+ return derive_index_key_seed_only(seed)
From 57adf0bf01528892440d8d8f4ecbb3351b166047 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Wed, 2 Jul 2025 19:00:49 -0400
Subject: [PATCH 27/43] Remove unused encryption mode setting
---
src/main.py | 54 ++++++-------------
src/password_manager/config_manager.py | 13 +----
src/password_manager/manager.py | 2 -
src/tests/test_cli_encryption_mode.py | 35 ------------
.../test_cli_portable_backup_commands.py | 2 +-
src/tests/test_default_encryption_mode.py | 11 ++++
6 files changed, 28 insertions(+), 89 deletions(-)
delete mode 100644 src/tests/test_cli_encryption_mode.py
create mode 100644 src/tests/test_default_encryption_mode.py
diff --git a/src/main.py b/src/main.py
index ef6a590..6e52519 100644
--- a/src/main.py
+++ b/src/main.py
@@ -17,7 +17,7 @@ import traceback
from password_manager.manager import PasswordManager
from nostr.client import NostrClient
from constants import INACTIVITY_TIMEOUT
-from utils.key_derivation import EncryptionMode
+
colorama_init()
@@ -491,14 +491,13 @@ def handle_settings(password_manager: PasswordManager) -> None:
print("1. Profiles")
print("2. Nostr")
print("3. Change password")
- 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. Set inactivity timeout")
- print("10. Lock Vault")
- print("11. Back")
+ print("4. Verify Script Checksum")
+ print("5. Backup Parent Seed")
+ print("6. Export database")
+ print("7. Import database")
+ print("8. Set inactivity timeout")
+ print("9. Lock Vault")
+ print("10. Back")
choice = input("Select an option: ").strip()
if choice == "1":
handle_profiles_menu(password_manager)
@@ -507,29 +506,22 @@ def handle_settings(password_manager: PasswordManager) -> None:
elif choice == "3":
password_manager.change_password()
elif choice == "4":
- 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_verify_checksum()
- elif choice == "6":
+ elif choice == "5":
password_manager.handle_backup_reveal_parent_seed()
- elif choice == "7":
+ elif choice == "6":
password_manager.handle_export_database()
- elif choice == "8":
+ elif choice == "7":
path = input("Enter path to backup file: ").strip()
if path:
password_manager.handle_import_database(Path(path))
- elif choice == "9":
+ elif choice == "8":
handle_set_inactivity_timeout(password_manager)
- elif choice == "10":
+ elif choice == "9":
password_manager.lock_vault()
print(colored("Vault locked. Please re-enter your password.", "yellow"))
password_manager.unlock_vault()
- elif choice == "11":
+ elif choice == "10":
break
else:
print(colored("Invalid choice.", "red"))
@@ -622,12 +614,6 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command")
- parser.add_argument(
- "--encryption-mode",
- choices=[m.value for m in EncryptionMode],
- help="Select encryption mode",
- )
-
exp = sub.add_parser("export")
exp.add_argument("--file")
@@ -636,19 +622,9 @@ if __name__ == "__main__":
args = parser.parse_args()
- mode_value = cfg.get("encryption_mode", EncryptionMode.SEED_ONLY.value)
- if args.encryption_mode:
- mode_value = args.encryption_mode
- try:
- enc_mode = EncryptionMode(mode_value)
- except ValueError:
- logger.error(f"Invalid encryption mode: {mode_value}")
- print(colored(f"Error: Invalid encryption mode '{mode_value}'", "red"))
- sys.exit(1)
-
# Initialize PasswordManager and proceed with application logic
try:
- password_manager = PasswordManager(encryption_mode=enc_mode)
+ password_manager = PasswordManager()
logger.info("PasswordManager initialized successfully.")
except Exception as e:
logger.error(f"Failed to initialize PasswordManager: {e}", exc_info=True)
diff --git a/src/password_manager/config_manager.py b/src/password_manager/config_manager.py
index eb689fb..5d9d620 100644
--- a/src/password_manager/config_manager.py
+++ b/src/password_manager/config_manager.py
@@ -12,10 +12,7 @@ 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,
-)
+
from constants import INACTIVITY_TIMEOUT
logger = logging.getLogger(__name__)
@@ -46,7 +43,6 @@ class ConfigManager:
"relays": list(DEFAULT_NOSTR_RELAYS),
"pin_hash": "",
"password_hash": "",
- "encryption_mode": DEFAULT_ENCRYPTION_MODE.value,
"inactivity_timeout": INACTIVITY_TIMEOUT,
}
try:
@@ -57,7 +53,6 @@ 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)
data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT)
# Migrate legacy hashed_password.enc if present and password_hash is missing
@@ -123,12 +118,6 @@ class ConfigManager:
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)
-
def set_inactivity_timeout(self, timeout_seconds: float) -> None:
"""Persist the inactivity timeout in seconds."""
if timeout_seconds <= 0:
diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py
index 9233cd5..ab096bb 100644
--- a/src/password_manager/manager.py
+++ b/src/password_manager/manager.py
@@ -696,7 +696,6 @@ 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)
@@ -1463,7 +1462,6 @@ class PasswordManager:
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
diff --git a/src/tests/test_cli_encryption_mode.py b/src/tests/test_cli_encryption_mode.py
deleted file mode 100644
index d5f235f..0000000
--- a/src/tests/test_cli_encryption_mode.py
+++ /dev/null
@@ -1,35 +0,0 @@
-import sys
-from pathlib import Path
-import argparse
-import pytest
-
-sys.path.append(str(Path(__file__).resolve().parents[1]))
-
-import main
-from utils.key_derivation import EncryptionMode
-from password_manager.manager import PasswordManager
-
-
-def _get_mode(monkeypatch, args=None, cfg=None):
- if args is None:
- args = []
- if cfg is None:
- cfg = {}
- monkeypatch.setattr(main, "load_global_config", lambda: cfg)
- monkeypatch.setattr(sys, "argv", ["prog"] + args)
- parser = argparse.ArgumentParser()
- parser.add_argument(
- "--encryption-mode",
- choices=[m.value for m in EncryptionMode],
- help="Select encryption mode",
- )
- parsed = parser.parse_args()
- mode_value = cfg.get("encryption_mode", EncryptionMode.SEED_ONLY.value)
- if parsed.encryption_mode:
- mode_value = parsed.encryption_mode
- return EncryptionMode(mode_value)
-
-
-def test_default_mode_is_seed_only(monkeypatch):
- mode = _get_mode(monkeypatch)
- assert mode is EncryptionMode.SEED_ONLY
diff --git a/src/tests/test_cli_portable_backup_commands.py b/src/tests/test_cli_portable_backup_commands.py
index 3b13dfb..c819588 100644
--- a/src/tests/test_cli_portable_backup_commands.py
+++ b/src/tests/test_cli_portable_backup_commands.py
@@ -15,7 +15,7 @@ def _run(argv, monkeypatch):
monkeypatch.setattr(main, "load_global_config", lambda: {})
called = {}
- def fake_init(self, encryption_mode):
+ def fake_init(self, *args, **kwargs):
called["init"] = True
def fake_export(self, dest):
diff --git a/src/tests/test_default_encryption_mode.py b/src/tests/test_default_encryption_mode.py
new file mode 100644
index 0000000..215d993
--- /dev/null
+++ b/src/tests/test_default_encryption_mode.py
@@ -0,0 +1,11 @@
+import sys
+from pathlib import Path
+
+sys.path.append(str(Path(__file__).resolve().parents[1]))
+
+from password_manager.manager import PasswordManager
+from utils.key_derivation import DEFAULT_ENCRYPTION_MODE
+
+
+def test_default_encryption_mode():
+ assert PasswordManager.__init__.__defaults__[0] is DEFAULT_ENCRYPTION_MODE
From 055e86b8ab4296779cd75e2a067bceb44d22a658 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Wed, 2 Jul 2025 19:17:52 -0400
Subject: [PATCH 28/43] Remove obsolete encryption mode support
---
src/password_manager/manager.py | 64 +----------------------
src/tests/test_default_encryption_mode.py | 24 +++++++--
2 files changed, 23 insertions(+), 65 deletions(-)
diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py
index ab096bb..a09fa57 100644
--- a/src/password_manager/manager.py
+++ b/src/password_manager/manager.py
@@ -29,7 +29,6 @@ from utils.key_derivation import (
derive_key_from_parent_seed,
derive_key_from_password,
derive_index_key,
- DEFAULT_ENCRYPTION_MODE,
EncryptionMode,
)
from utils.checksum import calculate_checksum, verify_checksum
@@ -79,11 +78,9 @@ class PasswordManager:
verification, ensuring the integrity and confidentiality of the stored password database.
"""
- def __init__(
- self, encryption_mode: EncryptionMode = DEFAULT_ENCRYPTION_MODE
- ) -> None:
+ def __init__(self) -> None:
"""Initialize the PasswordManager."""
- self.encryption_mode: EncryptionMode = encryption_mode
+ self.encryption_mode: EncryptionMode = EncryptionMode.SEED_ONLY
self.encryption_manager: Optional[EncryptionManager] = None
self.entry_manager: Optional[EntryManager] = None
self.password_generator: Optional[PasswordGenerator] = None
@@ -115,15 +112,6 @@ 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.
- """
- # Only seed-only mode is supported
- return EncryptionMode.SEED_ONLY
-
def lock_vault(self) -> None:
"""Clear sensitive information from memory."""
self.parent_seed = None
@@ -210,7 +198,6 @@ class PasswordManager:
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()
@@ -487,8 +474,6 @@ class PasswordManager:
"""
print(colored("No existing seed found. Let's set up a new one!", "yellow"))
- 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): "
).strip()
@@ -1403,7 +1388,6 @@ class PasswordManager:
config_data = self.config_manager.load_config(require_pin=False)
# Create a new encryption manager with the new password
- mode = getattr(self, "encryption_mode", DEFAULT_ENCRYPTION_MODE)
new_key = derive_index_key(self.parent_seed)
seed_key = derive_key_from_password(new_password)
@@ -1444,47 +1428,3 @@ 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)
- 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
- 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:
- 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}"
- )
- 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_default_encryption_mode.py b/src/tests/test_default_encryption_mode.py
index 215d993..dd0108f 100644
--- a/src/tests/test_default_encryption_mode.py
+++ b/src/tests/test_default_encryption_mode.py
@@ -3,9 +3,27 @@ from pathlib import Path
sys.path.append(str(Path(__file__).resolve().parents[1]))
+from types import SimpleNamespace
+from pathlib import Path
+
from password_manager.manager import PasswordManager
-from utils.key_derivation import DEFAULT_ENCRYPTION_MODE
+from utils.key_derivation import EncryptionMode
-def test_default_encryption_mode():
- assert PasswordManager.__init__.__defaults__[0] is DEFAULT_ENCRYPTION_MODE
+def test_default_encryption_mode(monkeypatch):
+ monkeypatch.setattr(
+ PasswordManager,
+ "initialize_fingerprint_manager",
+ lambda self: setattr(
+ self,
+ "fingerprint_manager",
+ SimpleNamespace(
+ get_current_fingerprint_dir=lambda: Path("./"),
+ list_fingerprints=lambda: [],
+ ),
+ ),
+ )
+ monkeypatch.setattr(PasswordManager, "setup_parent_seed", lambda self: None)
+
+ pm = PasswordManager()
+ assert pm.encryption_mode is EncryptionMode.SEED_ONLY
From a8a0e20c02bf94fbb8e96a5afac12b2ec54557f0 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Wed, 2 Jul 2025 19:26:42 -0400
Subject: [PATCH 29/43] Remove unused import
---
src/password_manager/portable_backup.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/password_manager/portable_backup.py b/src/password_manager/portable_backup.py
index 48ad688..d0effc4 100644
--- a/src/password_manager/portable_backup.py
+++ b/src/password_manager/portable_backup.py
@@ -18,7 +18,6 @@ from nostr.client import NostrClient
from utils.key_derivation import (
derive_index_key,
EncryptionMode,
- DEFAULT_ENCRYPTION_MODE,
)
from password_manager.encryption import EncryptionManager
from utils.checksum import json_checksum, canonical_json_dumps
From d6b4ac6642c651bdeb98a05f6b6fcfe1529453a5 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Wed, 2 Jul 2025 19:39:21 -0400
Subject: [PATCH 30/43] Remove legacy encryption mode tests
---
.../test_cli_portable_backup_commands.py | 46 -------------------
src/tests/test_manager_checksum_backup.py | 3 +-
src/tests/test_manager_workflow.py | 3 +-
src/tests/test_parent_seed_backup.py | 3 +-
src/tests/test_password_change.py | 3 +-
src/tests/test_profile_management.py | 2 +
src/tests/test_profiles.py | 3 +-
src/tests/test_seed_generation.py | 1 +
src/tests/test_seed_import.py | 3 +-
9 files changed, 15 insertions(+), 52 deletions(-)
delete mode 100644 src/tests/test_cli_portable_backup_commands.py
diff --git a/src/tests/test_cli_portable_backup_commands.py b/src/tests/test_cli_portable_backup_commands.py
deleted file mode 100644
index c819588..0000000
--- a/src/tests/test_cli_portable_backup_commands.py
+++ /dev/null
@@ -1,46 +0,0 @@
-import sys
-from pathlib import Path
-import runpy
-
-import pytest
-
-sys.path.append(str(Path(__file__).resolve().parents[1]))
-
-import main
-from password_manager.manager import PasswordManager
-
-
-def _run(argv, monkeypatch):
- monkeypatch.setattr(sys, "argv", ["seedpass"] + argv)
- monkeypatch.setattr(main, "load_global_config", lambda: {})
- called = {}
-
- def fake_init(self, *args, **kwargs):
- called["init"] = True
-
- def fake_export(self, dest):
- called["export"] = Path(dest)
-
- def fake_import(self, src):
- called["import"] = Path(src)
-
- monkeypatch.setattr(PasswordManager, "__init__", fake_init)
- monkeypatch.setattr(PasswordManager, "handle_export_database", fake_export)
- monkeypatch.setattr(PasswordManager, "handle_import_database", fake_import)
-
- with pytest.raises(SystemExit):
- runpy.run_module("main", run_name="__main__")
-
- return called
-
-
-def test_export_command_invokes_handler(monkeypatch):
- called = _run(["export", "--file", "out.json"], monkeypatch)
- assert called["export"] == Path("out.json")
- assert "import" not in called
-
-
-def test_import_command_invokes_handler(monkeypatch):
- called = _run(["import", "--file", "backup.json"], monkeypatch)
- assert called["import"] == Path("backup.json")
- assert "export" not in called
diff --git a/src/tests/test_manager_checksum_backup.py b/src/tests/test_manager_checksum_backup.py
index 4f625ca..5b15bae 100644
--- a/src/tests/test_manager_checksum_backup.py
+++ b/src/tests/test_manager_checksum_backup.py
@@ -3,7 +3,7 @@ from pathlib import Path
sys.path.append(str(Path(__file__).resolve().parents[1]))
-from password_manager.manager import PasswordManager
+from password_manager.manager import PasswordManager, EncryptionMode
class FakeBackupManager:
@@ -19,6 +19,7 @@ class FakeBackupManager:
def _make_pm():
pm = PasswordManager.__new__(PasswordManager)
+ pm.encryption_mode = EncryptionMode.SEED_ONLY
return pm
diff --git a/src/tests/test_manager_workflow.py b/src/tests/test_manager_workflow.py
index 7bc1f91..abb2bab 100644
--- a/src/tests/test_manager_workflow.py
+++ b/src/tests/test_manager_workflow.py
@@ -8,7 +8,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager
from password_manager.vault import Vault
from password_manager.backup import BackupManager
-from password_manager.manager import PasswordManager
+from password_manager.manager import PasswordManager, EncryptionMode
class FakePasswordGenerator:
@@ -34,6 +34,7 @@ def test_manager_workflow(monkeypatch):
monkeypatch.setattr("password_manager.manager.NostrClient", FakeNostrClient)
pm = PasswordManager.__new__(PasswordManager)
+ pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.vault = vault
pm.entry_manager = entry_mgr
diff --git a/src/tests/test_parent_seed_backup.py b/src/tests/test_parent_seed_backup.py
index 6c24184..728f8b0 100644
--- a/src/tests/test_parent_seed_backup.py
+++ b/src/tests/test_parent_seed_backup.py
@@ -5,12 +5,13 @@ from types import SimpleNamespace
sys.path.append(str(Path(__file__).resolve().parents[1]))
-from password_manager.manager import PasswordManager
+from password_manager.manager import PasswordManager, EncryptionMode
from constants import DEFAULT_SEED_BACKUP_FILENAME
def _make_pm(tmp_path: Path) -> PasswordManager:
pm = PasswordManager.__new__(PasswordManager)
+ pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.parent_seed = "seed phrase"
pm.fingerprint_dir = tmp_path
pm.encryption_manager = SimpleNamespace(encrypt_and_save_file=lambda *a, **k: None)
diff --git a/src/tests/test_password_change.py b/src/tests/test_password_change.py
index 8a4e4ea..83c0c61 100644
--- a/src/tests/test_password_change.py
+++ b/src/tests/test_password_change.py
@@ -11,7 +11,7 @@ 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 password_manager.manager import PasswordManager, EncryptionMode
def test_change_password_triggers_nostr_backup(monkeypatch):
@@ -22,6 +22,7 @@ def test_change_password_triggers_nostr_backup(monkeypatch):
cfg_mgr = ConfigManager(vault, fp)
pm = PasswordManager.__new__(PasswordManager)
+ pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.entry_manager = entry_mgr
pm.config_manager = cfg_mgr
diff --git a/src/tests/test_profile_management.py b/src/tests/test_profile_management.py
index 906a789..ff35a7c 100644
--- a/src/tests/test_profile_management.py
+++ b/src/tests/test_profile_management.py
@@ -14,6 +14,7 @@ import constants
import password_manager.manager as manager_module
from password_manager.vault import Vault
from password_manager.entry_management import EntryManager
+from password_manager.manager import EncryptionMode
def test_add_and_delete_entry(monkeypatch):
@@ -25,6 +26,7 @@ def test_add_and_delete_entry(monkeypatch):
importlib.reload(manager_module)
pm = manager_module.PasswordManager.__new__(manager_module.PasswordManager)
+ pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.fingerprint_manager = FingerprintManager(constants.APP_DIR)
pm.current_fingerprint = None
pm.save_and_encrypt_seed = lambda seed, fingerprint_dir: None
diff --git a/src/tests/test_profiles.py b/src/tests/test_profiles.py
index 43bdfc6..c6cf5ec 100644
--- a/src/tests/test_profiles.py
+++ b/src/tests/test_profiles.py
@@ -5,7 +5,7 @@ from tempfile import TemporaryDirectory
sys.path.append(str(Path(__file__).resolve().parents[1]))
from utils.fingerprint_manager import FingerprintManager
-from password_manager.manager import PasswordManager
+from password_manager.manager import PasswordManager, EncryptionMode
VALID_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
@@ -22,6 +22,7 @@ def test_add_and_switch_fingerprint(monkeypatch):
assert expected_dir.exists()
pm = PasswordManager.__new__(PasswordManager)
+ pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.fingerprint_manager = fm
pm.encryption_manager = object()
pm.current_fingerprint = None
diff --git a/src/tests/test_seed_generation.py b/src/tests/test_seed_generation.py
index d8bd2a8..99f6e57 100644
--- a/src/tests/test_seed_generation.py
+++ b/src/tests/test_seed_generation.py
@@ -17,6 +17,7 @@ def setup_password_manager():
importlib.reload(manager_module)
pm = manager_module.PasswordManager.__new__(manager_module.PasswordManager)
+ pm.encryption_mode = manager_module.EncryptionMode.SEED_ONLY
pm.fingerprint_manager = manager_module.FingerprintManager(constants.APP_DIR)
pm.current_fingerprint = None
pm.save_and_encrypt_seed = lambda seed, fingerprint_dir: None
diff --git a/src/tests/test_seed_import.py b/src/tests/test_seed_import.py
index d3b3088..eb6db7f 100644
--- a/src/tests/test_seed_import.py
+++ b/src/tests/test_seed_import.py
@@ -8,7 +8,7 @@ from mnemonic import Mnemonic
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.encryption import EncryptionManager
-from password_manager.manager import PasswordManager
+from password_manager.manager import PasswordManager, EncryptionMode
def test_seed_encryption_round_trip():
@@ -22,4 +22,5 @@ def test_seed_encryption_round_trip():
assert decrypted == seed
pm = PasswordManager.__new__(PasswordManager)
+ pm.encryption_mode = EncryptionMode.SEED_ONLY
assert pm.validate_bip85_seed(seed)
From 7b35e961be5464b3adf6f261bd8e5d7865d47d0a Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Wed, 2 Jul 2025 19:46:15 -0400
Subject: [PATCH 31/43] docs: remove encryption mode sections
---
README.md | 29 +++--------------------------
docs/advanced_cli.md | 26 --------------------------
2 files changed, 3 insertions(+), 52 deletions(-)
diff --git a/README.md b/README.md
index ef6ed5e..f2bd9f7 100644
--- a/README.md
+++ b/README.md
@@ -111,11 +111,11 @@ SeedPass and create a backup:
# Start the application
python src/main.py
-# Export your index using seed-only encryption
-seedpass export --mode seed-only --file "~/seedpass_backup.json"
+# Export your index
+seedpass export --file "~/seedpass_backup.json"
# Later you can restore it
-seedpass import --mode seed-only --file "~/seedpass_backup.json"
+seedpass import --file "~/seedpass_backup.json"
```
@@ -154,29 +154,6 @@ python src/main.py
Enter your choice (1-5):
```
-### Encryption Mode
-
-Use the `--encryption-mode` flag to control how SeedPass derives the key used to
-encrypt your vault. Valid values are:
-
-- `seed-only` – default mode that derives the vault key solely from your BIP-85
- seed.
-- `seed+pw` – combines the seed with your master password for key derivation.
-- `pw-only` – derives the key from your password alone.
-
-You can set this option when launching the application:
-
-```bash
-python src/main.py --encryption-mode seed+pw
-```
-
-To make the choice persistent, add it to `~/.seedpass/config.toml`:
-
-```toml
-encryption_mode = "seed+pw"
-```
-
-SeedPass will read this value on startup and use the specified mode by default.
### Managing Multiple Seeds
diff --git a/docs/advanced_cli.md b/docs/advanced_cli.md
index 983f4bd..ea5dc15 100644
--- a/docs/advanced_cli.md
+++ b/docs/advanced_cli.md
@@ -219,23 +219,6 @@ seedpass export --file "backup_passwords.json"
**Options:**
- `--file` (`-F`): The destination file path for the exported data. If omitted, the export
is saved to the current profile's `exports` directory under `~/.seedpass//exports/`.
-- `--mode` (`-M`): Choose the encryption mode for the exported file. Valid values are:
- `seed-only`, `seed+pw`, `pw-only`, and `plaintext`.
-
-**Examples:**
-```bash
-# Standard encrypted export
-seedpass export --mode seed-only --file "backup.json"
-# Combine seed and master password for the export key
-seedpass export --mode seed+pw --file "backup.json"
-# Derive the key solely from your password
-seedpass export --mode pw-only --file "backup.json"
-# Plaintext JSON export (not recommended)
-seedpass export --mode plaintext --file "backup.json"
-```
-
-**Warning:** The `plaintext` mode writes an unencrypted index to disk. Only use it
-for debugging and delete the file immediately after use.
---
@@ -255,15 +238,6 @@ seedpass import --file "backup_passwords.json"
**Options:**
- `--file` (`-F`): The source file path containing the password entries to import.
-- `--mode` (`-M`): Indicates the encryption mode used when the file was exported. Accepted values are `seed-only`, `seed+pw`, `pw-only`, and `plaintext`.
-
-**Examples:**
-```bash
-# Import a standard encrypted backup
-seedpass import --mode seed-only --file "backup.json"
-# Import a backup that also used the master password
-seedpass import --mode seed+pw --file "backup.json"
-```
---
From b6675aa5ece91cb1f44470ee07692e0c12b1e11b Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Wed, 2 Jul 2025 20:14:25 -0400
Subject: [PATCH 32/43] Display event IDs after Nostr publish
---
src/main.py | 11 ++++++++---
src/nostr/client.py | 13 +++++++------
src/password_manager/manager.py | 14 +++++++-------
src/tests/test_manager_workflow.py | 1 +
src/tests/test_nostr_backup.py | 4 ++--
src/tests/test_nostr_dummy_client.py | 4 ++--
src/tests/test_password_change.py | 2 +-
src/tests/test_password_unlock_after_change.py | 8 ++++++--
src/tests/test_post_sync_messages.py | 5 +++--
src/tests/test_profile_management.py | 5 ++++-
src/tests/test_publish_json_result.py | 3 ++-
src/tests/test_settings_menu.py | 2 +-
12 files changed, 44 insertions(+), 28 deletions(-)
diff --git a/src/main.py b/src/main.py
index 6e52519..97fc1e3 100644
--- a/src/main.py
+++ b/src/main.py
@@ -226,9 +226,14 @@ def handle_post_to_nostr(
Handles the action of posting the encrypted password index to Nostr.
"""
try:
- success = password_manager.sync_vault(alt_summary=alt_summary)
- if success:
- print(colored("\N{WHITE HEAVY CHECK MARK} Sync complete.", "green"))
+ event_id = password_manager.sync_vault(alt_summary=alt_summary)
+ if event_id:
+ print(
+ colored(
+ f"\N{WHITE HEAVY CHECK MARK} Sync complete. Event ID: {event_id}",
+ "green",
+ )
+ )
logging.info("Encrypted index posted to Nostr successfully.")
else:
print(colored("\N{CROSS MARK} Sync failed…", "red"))
diff --git a/src/nostr/client.py b/src/nostr/client.py
index c2aee6e..20fd2f3 100644
--- a/src/nostr/client.py
+++ b/src/nostr/client.py
@@ -142,7 +142,7 @@ class NostrClient:
encrypted_json: bytes,
to_pubkey: str | None = None,
alt_summary: str | None = None,
- ) -> bool:
+ ) -> str | None:
"""Builds and publishes a Kind 1 text note or direct message.
Parameters
@@ -177,12 +177,12 @@ class NostrClient:
else str(event_output)
)
logger.info(f"Successfully published event with ID: {event_id_hex}")
- return True
+ return event_id_hex
except Exception as e:
self.last_error = str(e)
logger.error(f"Failed to publish JSON to Nostr: {e}")
- return False
+ return None
def publish_event(self, event):
"""Publish a prepared event to the configured relays."""
@@ -242,7 +242,7 @@ class NostrClient:
async def publish_snapshot(
self, encrypted_bytes: bytes, limit: int = 50_000
- ) -> Manifest:
+ ) -> tuple[Manifest, str]:
"""Publish a compressed snapshot split into chunks.
Parameters
@@ -276,10 +276,11 @@ class NostrClient:
.build(self.keys.public_key())
.sign_with_keys(self.keys)
)
- await self.client.send_event(manifest_event)
+ result = await self.client.send_event(manifest_event)
+ manifest_id = result.id.to_hex() if hasattr(result, "id") else str(result)
self.current_manifest = manifest
self._delta_events = []
- return manifest
+ return manifest, manifest_id
async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None:
"""Retrieve the latest manifest and all snapshot chunks."""
diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py
index a09fa57..21cfd22 100644
--- a/src/password_manager/manager.py
+++ b/src/password_manager/manager.py
@@ -1129,26 +1129,26 @@ 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:
+ def sync_vault(self, alt_summary: str | None = None) -> str | None:
"""Publish the current vault contents to Nostr."""
try:
encrypted = self.get_encrypted_data()
if not encrypted:
- return False
+ return None
pub_snap = getattr(self.nostr_client, "publish_snapshot", None)
if callable(pub_snap):
if asyncio.iscoroutinefunction(pub_snap):
- asyncio.run(pub_snap(encrypted))
+ _, event_id = asyncio.run(pub_snap(encrypted))
else:
- pub_snap(encrypted)
+ _, event_id = pub_snap(encrypted)
else:
# Fallback for tests using simplified stubs
- self.nostr_client.publish_json_to_nostr(encrypted)
+ event_id = self.nostr_client.publish_json_to_nostr(encrypted)
self.is_dirty = False
- return True
+ return event_id
except Exception as e:
logging.error(f"Failed to sync vault: {e}", exc_info=True)
- return False
+ return None
def backup_database(self) -> None:
"""
diff --git a/src/tests/test_manager_workflow.py b/src/tests/test_manager_workflow.py
index abb2bab..009c640 100644
--- a/src/tests/test_manager_workflow.py
+++ b/src/tests/test_manager_workflow.py
@@ -22,6 +22,7 @@ class FakeNostrClient:
def publish_snapshot(self, data: bytes):
self.published.append(data)
+ return None, "abcd"
def test_manager_workflow(monkeypatch):
diff --git a/src/tests/test_nostr_backup.py b/src/tests/test_nostr_backup.py
index b9faca4..0ab10f8 100644
--- a/src/tests/test_nostr_backup.py
+++ b/src/tests/test_nostr_backup.py
@@ -25,7 +25,7 @@ def test_backup_and_publish_to_nostr():
with patch(
"nostr.client.NostrClient.publish_snapshot",
- AsyncMock(return_value=None),
+ AsyncMock(return_value=(None, "abcd")),
) as mock_publish, patch("nostr.client.ClientBuilder"), patch(
"nostr.client.KeyManager"
), patch.object(
@@ -38,4 +38,4 @@ def test_backup_and_publish_to_nostr():
result = asyncio.run(nostr_client.publish_snapshot(encrypted_index))
mock_publish.assert_awaited_with(encrypted_index)
- assert result is None
+ assert result == (None, "abcd")
diff --git a/src/tests/test_nostr_dummy_client.py b/src/tests/test_nostr_dummy_client.py
index cd1ab1a..fe4e998 100644
--- a/src/tests/test_nostr_dummy_client.py
+++ b/src/tests/test_nostr_dummy_client.py
@@ -29,7 +29,7 @@ def test_retrieve_multi_chunk_snapshot(dummy_nostr_client):
client, relay = dummy_nostr_client
data = os.urandom(120000)
- manifest = asyncio.run(client.publish_snapshot(data, limit=50000))
+ 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)
@@ -40,7 +40,7 @@ def test_retrieve_multi_chunk_snapshot(dummy_nostr_client):
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, _ = asyncio.run(client.publish_snapshot(base))
manifest_id = relay.manifests[-1].id
d1 = b"d1"
d2 = b"d2"
diff --git a/src/tests/test_password_change.py b/src/tests/test_password_change.py
index 83c0c61..5be68fb 100644
--- a/src/tests/test_password_change.py
+++ b/src/tests/test_password_change.py
@@ -43,7 +43,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)
+ mock_instance.publish_snapshot = AsyncMock(return_value=(None, "abcd"))
pm.nostr_client = mock_instance
pm.change_password()
mock_instance.publish_snapshot.assert_called_once()
diff --git a/src/tests/test_password_unlock_after_change.py b/src/tests/test_password_unlock_after_change.py
index 38e11e2..c548135 100644
--- a/src/tests/test_password_unlock_after_change.py
+++ b/src/tests/test_password_unlock_after_change.py
@@ -54,7 +54,9 @@ def test_password_change_and_unlock(monkeypatch):
pm.fingerprint_dir = fp
pm.current_fingerprint = "fp"
pm.parent_seed = SEED
- pm.nostr_client = SimpleNamespace(publish_snapshot=lambda *a, **k: None)
+ pm.nostr_client = SimpleNamespace(
+ publish_snapshot=lambda *a, **k: (None, "abcd")
+ )
monkeypatch.setattr(
"password_manager.manager.prompt_existing_password", lambda *_: old_pw
@@ -64,7 +66,9 @@ def test_password_change_and_unlock(monkeypatch):
)
monkeypatch.setattr(
"password_manager.manager.NostrClient",
- lambda *a, **kw: SimpleNamespace(publish_snapshot=lambda *a, **k: None),
+ lambda *a, **kw: SimpleNamespace(
+ publish_snapshot=lambda *a, **k: (None, "abcd")
+ ),
)
pm.change_password()
diff --git a/src/tests/test_post_sync_messages.py b/src/tests/test_post_sync_messages.py
index 2a4e95e..2491217 100644
--- a/src/tests/test_post_sync_messages.py
+++ b/src/tests/test_post_sync_messages.py
@@ -9,16 +9,17 @@ import main
def test_handle_post_success(capsys):
pm = SimpleNamespace(
- sync_vault=lambda alt_summary=None: True,
+ sync_vault=lambda alt_summary=None: "abcd",
)
main.handle_post_to_nostr(pm)
out = capsys.readouterr().out
assert "✅ Sync complete." in out
+ assert "abcd" in out
def test_handle_post_failure(capsys):
pm = SimpleNamespace(
- sync_vault=lambda alt_summary=None: False,
+ sync_vault=lambda alt_summary=None: None,
)
main.handle_post_to_nostr(pm)
out = capsys.readouterr().out
diff --git a/src/tests/test_profile_management.py b/src/tests/test_profile_management.py
index ff35a7c..de0635b 100644
--- a/src/tests/test_profile_management.py
+++ b/src/tests/test_profile_management.py
@@ -62,7 +62,10 @@ def test_add_and_delete_entry(monkeypatch):
published = []
pm.nostr_client = SimpleNamespace(
- publish_snapshot=lambda data, alt_summary=None: published.append(data)
+ publish_snapshot=lambda data, alt_summary=None: (
+ published.append(data),
+ (None, "abcd"),
+ )[1]
)
inputs = iter([str(index)])
diff --git a/src/tests/test_publish_json_result.py b/src/tests/test_publish_json_result.py
index 08f97ce..176c968 100644
--- a/src/tests/test_publish_json_result.py
+++ b/src/tests/test_publish_json_result.py
@@ -81,8 +81,9 @@ def test_publish_snapshot_success():
with patch.object(
client.client, "send_event", side_effect=fake_send
) as mock_send:
- manifest = asyncio.run(client.publish_snapshot(b"data"))
+ manifest, event_id = asyncio.run(client.publish_snapshot(b"data"))
assert isinstance(manifest, Manifest)
+ assert event_id == "abcd"
assert mock_send.await_count >= 1
diff --git a/src/tests/test_settings_menu.py b/src/tests/test_settings_menu.py
index 668cc04..4bf2cb1 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_snapshot=lambda data, alt_summary=None: None,
+ publish_snapshot=lambda data, alt_summary=None: (None, "abcd"),
key_manager=SimpleNamespace(get_npub=lambda: "npub"),
)
From 92b411beb373aa82f5b5b87d75cf712310e2bc40 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Wed, 2 Jul 2025 21:07:13 -0400
Subject: [PATCH 33/43] Add EntryType enum
---
src/password_manager/__init__.py | 4 +++-
src/password_manager/entry_types.py | 13 +++++++++++++
2 files changed, 16 insertions(+), 1 deletion(-)
create mode 100644 src/password_manager/entry_types.py
diff --git a/src/password_manager/__init__.py b/src/password_manager/__init__.py
index 97b0288..fd7cf15 100644
--- a/src/password_manager/__init__.py
+++ b/src/password_manager/__init__.py
@@ -4,7 +4,7 @@
from importlib import import_module
-__all__ = ["PasswordManager", "ConfigManager", "Vault"]
+__all__ = ["PasswordManager", "ConfigManager", "Vault", "EntryType"]
def __getattr__(name: str):
@@ -14,4 +14,6 @@ def __getattr__(name: str):
return import_module(".config_manager", __name__).ConfigManager
if name == "Vault":
return import_module(".vault", __name__).Vault
+ if name == "EntryType":
+ return import_module(".entry_types", __name__).EntryType
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
diff --git a/src/password_manager/entry_types.py b/src/password_manager/entry_types.py
new file mode 100644
index 0000000..bfdc5c5
--- /dev/null
+++ b/src/password_manager/entry_types.py
@@ -0,0 +1,13 @@
+# password_manager/entry_types.py
+"""Enumerations for entry types used by SeedPass."""
+
+from enum import Enum
+
+
+class EntryType(str, Enum):
+ """Enumeration of different entry types supported by the manager."""
+
+ PASSWORD = "password"
+ TOTP = "totp"
+ SSH = "ssh"
+ SEED = "seed"
From c83b8c307418b1186ca332be99ca9340a32e2d21 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Wed, 2 Jul 2025 21:29:18 -0400
Subject: [PATCH 34/43] Introduce custom error classes
---
src/local_bip85/bip85.py | 20 ++++++++++++++------
src/main.py | 16 ++++++++++++++++
src/password_manager/manager.py | 6 +++++-
src/tests/test_bip85_vectors.py | 6 +++---
src/utils/password_prompt.py | 26 ++++++++++++++++----------
5 files changed, 54 insertions(+), 20 deletions(-)
diff --git a/src/local_bip85/bip85.py b/src/local_bip85/bip85.py
index 025292b..495d675 100644
--- a/src/local_bip85/bip85.py
+++ b/src/local_bip85/bip85.py
@@ -31,6 +31,12 @@ from cryptography.hazmat.backends import default_backend
logger = logging.getLogger(__name__)
+class Bip85Error(Exception):
+ """Exception raised for BIP85-related errors."""
+
+ pass
+
+
class BIP85:
def __init__(self, seed_bytes: bytes | str):
"""Initialize from BIP39 seed bytes or BIP32 xprv string."""
@@ -43,7 +49,7 @@ class BIP85:
except Exception as e:
logging.error(f"Error initializing BIP32 context: {e}", exc_info=True)
print(f"{Fore.RED}Error initializing BIP32 context: {e}")
- sys.exit(1)
+ raise Bip85Error(f"Error initializing BIP32 context: {e}")
def derive_entropy(
self, index: int, bytes_len: int, app_no: int = 39, words_len: int | None = None
@@ -90,21 +96,23 @@ class BIP85:
print(
f"{Fore.RED}Error: Derived entropy length is {len(entropy)} bytes; expected {bytes_len} bytes."
)
- sys.exit(1)
+ raise Bip85Error(
+ f"Derived entropy length is {len(entropy)} bytes; expected {bytes_len} bytes."
+ )
logging.debug(f"Derived entropy: {entropy.hex()}")
return entropy
except Exception as e:
logging.error(f"Error deriving entropy: {e}", exc_info=True)
print(f"{Fore.RED}Error deriving entropy: {e}")
- sys.exit(1)
+ raise Bip85Error(f"Error deriving entropy: {e}")
def derive_mnemonic(self, index: int, words_num: int) -> str:
bytes_len = {12: 16, 18: 24, 24: 32}.get(words_num)
if not bytes_len:
logging.error(f"Unsupported number of words: {words_num}")
print(f"{Fore.RED}Error: Unsupported number of words: {words_num}")
- sys.exit(1)
+ raise Bip85Error(f"Unsupported number of words: {words_num}")
entropy = self.derive_entropy(
index=index, bytes_len=bytes_len, app_no=39, words_len=words_num
@@ -118,7 +126,7 @@ class BIP85:
except Exception as e:
logging.error(f"Error generating mnemonic: {e}", exc_info=True)
print(f"{Fore.RED}Error generating mnemonic: {e}")
- sys.exit(1)
+ raise Bip85Error(f"Error generating mnemonic: {e}")
def derive_symmetric_key(self, index: int = 0, app_no: int = 2) -> bytes:
"""Derive 32 bytes of entropy for symmetric key usage."""
@@ -129,4 +137,4 @@ class BIP85:
except Exception as e:
logging.error(f"Error deriving symmetric key: {e}", exc_info=True)
print(f"{Fore.RED}Error deriving symmetric key: {e}")
- sys.exit(1)
+ raise Bip85Error(f"Error deriving symmetric key: {e}")
diff --git a/src/main.py b/src/main.py
index 97fc1e3..bd4182a 100644
--- a/src/main.py
+++ b/src/main.py
@@ -17,6 +17,8 @@ import traceback
from password_manager.manager import PasswordManager
from nostr.client import NostrClient
from constants import INACTIVITY_TIMEOUT
+from utils.password_prompt import PasswordPromptError
+from local_bip85.bip85 import Bip85Error
colorama_init()
@@ -631,6 +633,10 @@ if __name__ == "__main__":
try:
password_manager = PasswordManager()
logger.info("PasswordManager initialized successfully.")
+ except (PasswordPromptError, Bip85Error) as e:
+ logger.error(f"Failed to initialize PasswordManager: {e}", exc_info=True)
+ print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red"))
+ sys.exit(1)
except Exception as e:
logger.error(f"Failed to initialize PasswordManager: {e}", exc_info=True)
print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red"))
@@ -677,6 +683,16 @@ if __name__ == "__main__":
logging.error(f"Error during shutdown: {e}")
print(colored(f"Error during shutdown: {e}", "red"))
sys.exit(0)
+ except (PasswordPromptError, Bip85Error) as e:
+ logger.error(f"A user-related error occurred: {e}", exc_info=True)
+ print(colored(f"Error: {e}", "red"))
+ try:
+ password_manager.nostr_client.close_client_pool()
+ logging.info("NostrClient closed successfully.")
+ except Exception as close_error:
+ logging.error(f"Error during shutdown: {close_error}")
+ print(colored(f"Error during shutdown: {close_error}", "red"))
+ sys.exit(1)
except Exception as e:
logger.error(f"An unexpected error occurred: {e}", exc_info=True)
print(colored(f"Error: An unexpected error occurred: {e}", "red"))
diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py
index 21cfd22..10dffcb 100644
--- a/src/password_manager/manager.py
+++ b/src/password_manager/manager.py
@@ -55,7 +55,7 @@ import gzip
import bcrypt
from pathlib import Path
-from local_bip85.bip85 import BIP85
+from local_bip85.bip85 import BIP85, Bip85Error
from bip_utils import Bip39SeedGenerator, Bip39MnemonicGenerator, Bip39Languages
from datetime import datetime
@@ -645,6 +645,10 @@ class PasswordManager:
bip85 = BIP85(master_seed)
mnemonic = bip85.derive_mnemonic(index=0, words_num=12)
return mnemonic
+ except Bip85Error as e:
+ logging.error(f"Failed to generate BIP-85 seed: {e}", exc_info=True)
+ print(colored(f"Error: Failed to generate BIP-85 seed: {e}", "red"))
+ sys.exit(1)
except Exception as e:
logging.error(f"Failed to generate BIP-85 seed: {e}", exc_info=True)
print(colored(f"Error: Failed to generate BIP-85 seed: {e}", "red"))
diff --git a/src/tests/test_bip85_vectors.py b/src/tests/test_bip85_vectors.py
index 8e77459..1ef3a73 100644
--- a/src/tests/test_bip85_vectors.py
+++ b/src/tests/test_bip85_vectors.py
@@ -4,7 +4,7 @@ import pytest
sys.path.append(str(Path(__file__).resolve().parents[1]))
-from local_bip85.bip85 import BIP85
+from local_bip85.bip85 import BIP85, Bip85Error
MASTER_XPRV = "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb"
@@ -33,7 +33,7 @@ def test_bip85_symmetric_key(bip85):
def test_invalid_params(bip85):
- with pytest.raises(SystemExit):
+ with pytest.raises(Bip85Error):
bip85.derive_mnemonic(index=0, words_num=15)
- with pytest.raises(SystemExit):
+ with pytest.raises(Bip85Error):
bip85.derive_mnemonic(index=-1, words_num=12)
diff --git a/src/utils/password_prompt.py b/src/utils/password_prompt.py
index 79ba195..de380d3 100644
--- a/src/utils/password_prompt.py
+++ b/src/utils/password_prompt.py
@@ -29,6 +29,12 @@ colorama_init()
logger = logging.getLogger(__name__)
+class PasswordPromptError(Exception):
+ """Exception raised for password prompt errors."""
+
+ pass
+
+
def prompt_new_password() -> str:
"""
Prompts the user to enter and confirm a new password for encrypting the parent seed.
@@ -40,7 +46,7 @@ def prompt_new_password() -> str:
str: The confirmed password entered by the user.
Raises:
- SystemExit: If the user fails to provide a valid password after multiple attempts.
+ PasswordPromptError: If the user fails to provide a valid password after multiple attempts.
"""
max_retries = 5
attempts = 0
@@ -87,7 +93,7 @@ def prompt_new_password() -> str:
except KeyboardInterrupt:
print(colored("\nOperation cancelled by user.", "yellow"))
logging.info("Password prompt interrupted by user.")
- sys.exit(0)
+ raise PasswordPromptError("Operation cancelled by user")
except Exception as e:
logging.error(
f"Unexpected error during password prompt: {e}", exc_info=True
@@ -97,7 +103,7 @@ def prompt_new_password() -> str:
print(colored("Maximum password attempts exceeded. Exiting.", "red"))
logging.error("User failed to provide a valid password after multiple attempts.")
- sys.exit(1)
+ raise PasswordPromptError("Maximum password attempts exceeded")
def prompt_existing_password(prompt_message: str = "Enter your password: ") -> str:
@@ -113,7 +119,7 @@ def prompt_existing_password(prompt_message: str = "Enter your password: ") -> s
str: The password entered by the user.
Raises:
- SystemExit: If the user interrupts the operation.
+ PasswordPromptError: If the user interrupts the operation.
"""
try:
password = getpass.getpass(prompt=prompt_message).strip()
@@ -121,7 +127,7 @@ def prompt_existing_password(prompt_message: str = "Enter your password: ") -> s
if not password:
print(colored("Error: Password cannot be empty.", "red"))
logging.warning("User attempted to enter an empty password.")
- sys.exit(1)
+ raise PasswordPromptError("Password cannot be empty")
# Normalize the password to NFKD form
normalized_password = unicodedata.normalize("NFKD", password)
@@ -131,13 +137,13 @@ def prompt_existing_password(prompt_message: str = "Enter your password: ") -> s
except KeyboardInterrupt:
print(colored("\nOperation cancelled by user.", "yellow"))
logging.info("Existing password prompt interrupted by user.")
- sys.exit(0)
+ raise PasswordPromptError("Operation cancelled by user")
except Exception as e:
logging.error(
f"Unexpected error during existing password prompt: {e}", exc_info=True
)
print(colored(f"Error: {e}", "red"))
- sys.exit(1)
+ raise PasswordPromptError(str(e))
def confirm_action(
@@ -154,7 +160,7 @@ def confirm_action(
bool: True if the user confirms the action, False otherwise.
Raises:
- SystemExit: If the user interrupts the operation.
+ PasswordPromptError: If the user interrupts the operation.
"""
try:
while True:
@@ -171,13 +177,13 @@ def confirm_action(
except KeyboardInterrupt:
print(colored("\nOperation cancelled by user.", "yellow"))
logging.info("Action confirmation interrupted by user.")
- sys.exit(0)
+ raise PasswordPromptError("Operation cancelled by user")
except Exception as e:
logging.error(
f"Unexpected error during action confirmation: {e}", exc_info=True
)
print(colored(f"Error: {e}", "red"))
- sys.exit(1)
+ raise PasswordPromptError(str(e))
def prompt_for_password() -> str:
From fb9b9e660751b0eb07c347a1f80419e050623bad Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Wed, 2 Jul 2025 21:56:50 -0400
Subject: [PATCH 35/43] Remove bip85 dependency and unused KeyManager
---
requirements.lock | 1 -
src/requirements.txt | 1 -
src/utils/key_derivation.py | 42 -------------------------------------
3 files changed, 44 deletions(-)
diff --git a/requirements.lock b/requirements.lock
index fe4ebd0..c84c4b0 100644
--- a/requirements.lock
+++ b/requirements.lock
@@ -6,7 +6,6 @@ base58==2.1.1
bcrypt==4.3.0
bech32==1.2.0
bip-utils==2.9.3
-bip85==0.2.0
cbor2==5.6.5
certifi==2025.6.15
cffi==1.17.1
diff --git a/src/requirements.txt b/src/requirements.txt
index 4514dc2..9af5f20 100644
--- a/src/requirements.txt
+++ b/src/requirements.txt
@@ -7,7 +7,6 @@ coincurve>=18.0.0
mnemonic
aiohttp
bcrypt
-bip85
pytest>=7.0
pytest-cov
pytest-xdist
diff --git a/src/utils/key_derivation.py b/src/utils/key_derivation.py
index 0733424..837cd5c 100644
--- a/src/utils/key_derivation.py
+++ b/src/utils/key_derivation.py
@@ -23,12 +23,7 @@ import traceback
from enum import Enum
from typing import Optional, Union
from bip_utils import Bip39SeedGenerator
-from local_bip85.bip85 import BIP85
-try:
- from monstr.encrypt import Keys
-except ImportError: # Fall back to local coincurve implementation
- from nostr.coincurve_keys import Keys
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.backends import default_backend
@@ -140,43 +135,6 @@ def derive_key_from_parent_seed(parent_seed: str, fingerprint: str = None) -> by
raise
-class KeyManager:
- def __init__(self, parent_seed: str, fingerprint: str = None):
- self.parent_seed = parent_seed
- self.fingerprint = fingerprint
- self.bip85 = self.initialize_bip85()
- self.keys = self.generate_nostr_keys()
-
- def initialize_bip85(self):
- seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate()
- bip85 = BIP85(seed_bytes)
- return bip85
-
- def generate_nostr_keys(self) -> Keys:
- """
- Derives a unique Nostr key pair for the given fingerprint using BIP-85.
-
- :return: An instance of Keys containing the Nostr key pair.
- """
- # Use a derivation path that includes the fingerprint
- # Convert fingerprint to an integer index (e.g., using a hash function)
- index = (
- int(hashlib.sha256(self.fingerprint.encode()).hexdigest(), 16) % (2**31)
- if self.fingerprint
- else 0
- )
-
- # Derive entropy for Nostr key (32 bytes)
- entropy_bytes = self.bip85.derive_entropy(
- app=BIP85.Applications.ENTROPY, index=index, size=32
- )
-
- # Generate Nostr key pair from entropy
- private_key_hex = entropy_bytes.hex()
- keys = Keys(priv_key=private_key_hex)
- return keys
-
-
def derive_index_key_seed_only(seed: str) -> bytes:
"""Derive a deterministic Fernet key from only the BIP-39 seed."""
seed_bytes = Bip39SeedGenerator(seed).Generate()
From 7763d9de8825c793c380c01db2e1d38a6ee48e25 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Wed, 2 Jul 2025 22:04:52 -0400
Subject: [PATCH 36/43] Refactor constants initialization
---
examples/entry_management_demo.py | 2 ++
examples/password_manager_demo.py | 2 ++
scripts/update_checksum.py | 3 +-
src/constants.py | 53 +++++++++++++------------------
src/main.py | 3 +-
src/password_manager/manager.py | 2 ++
6 files changed, 32 insertions(+), 33 deletions(-)
diff --git a/examples/entry_management_demo.py b/examples/entry_management_demo.py
index cebfeae..030187d 100644
--- a/examples/entry_management_demo.py
+++ b/examples/entry_management_demo.py
@@ -4,10 +4,12 @@ from cryptography.fernet import Fernet
from password_manager.encryption import EncryptionManager
from password_manager.vault import Vault
from password_manager.entry_management import EntryManager
+from constants import initialize_app
def main() -> None:
"""Demonstrate basic EntryManager usage."""
+ initialize_app()
key = Fernet.generate_key()
enc = EncryptionManager(key, Path("."))
vault = Vault(enc, Path("."))
diff --git a/examples/password_manager_demo.py b/examples/password_manager_demo.py
index 7796191..27da95f 100644
--- a/examples/password_manager_demo.py
+++ b/examples/password_manager_demo.py
@@ -1,9 +1,11 @@
from password_manager.manager import PasswordManager
from nostr.client import NostrClient
+from constants import initialize_app
def main() -> None:
"""Show how to initialise PasswordManager with Nostr support."""
+ initialize_app()
manager = PasswordManager()
manager.nostr_client = NostrClient(encryption_manager=manager.encryption_manager)
# Sample actions could be called on ``manager`` here.
diff --git a/scripts/update_checksum.py b/scripts/update_checksum.py
index de3da97..aa9f8fc 100644
--- a/scripts/update_checksum.py
+++ b/scripts/update_checksum.py
@@ -8,11 +8,12 @@ if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
from utils.checksum import calculate_checksum
-from constants import SCRIPT_CHECKSUM_FILE
+from constants import SCRIPT_CHECKSUM_FILE, initialize_app
def main() -> None:
"""Calculate checksum for the main script and write it to SCRIPT_CHECKSUM_FILE."""
+ initialize_app()
script_path = SRC_DIR / "password_manager" / "manager.py"
checksum = calculate_checksum(str(script_path))
if checksum is None:
diff --git a/src/constants.py b/src/constants.py
index 9eec419..6a86868 100644
--- a/src/constants.py
+++ b/src/constants.py
@@ -1,10 +1,7 @@
# constants.py
-import os
import logging
-import sys
from pathlib import Path
-import traceback
# Instantiate the logger
logger = logging.getLogger(__name__)
@@ -15,38 +12,32 @@ logger = logging.getLogger(__name__)
MAX_RETRIES = 3 # Maximum number of retries for relay connections
RETRY_DELAY = 5 # Seconds to wait before retrying a failed connection
-try:
- # -----------------------------------
- # Application Directory and Paths
- # -----------------------------------
- APP_DIR = Path.home() / ".seedpass"
- APP_DIR.mkdir(exist_ok=True, parents=True) # Ensure the directory exists
- if logger.isEnabledFor(logging.DEBUG):
- logger.info(f"Application directory created at {APP_DIR}")
-except Exception as e:
- if logger.isEnabledFor(logging.DEBUG):
- logger.error(f"Failed to create application directory: {e}", exc_info=True)
-
-try:
- PARENT_SEED_FILE = APP_DIR / "parent_seed.enc" # Encrypted parent seed
- if logger.isEnabledFor(logging.DEBUG):
- logger.info(f"Parent seed file path set to {PARENT_SEED_FILE}")
-except Exception as e:
- if logger.isEnabledFor(logging.DEBUG):
- logger.error(f"Error setting file paths: {e}", exc_info=True)
+# -----------------------------------
+# Application Directory and Paths
+# -----------------------------------
+APP_DIR = Path.home() / ".seedpass"
+PARENT_SEED_FILE = APP_DIR / "parent_seed.enc" # Encrypted parent seed
# -----------------------------------
# Checksum Files for Integrity
# -----------------------------------
-try:
- SCRIPT_CHECKSUM_FILE = (
- APP_DIR / "seedpass_script_checksum.txt"
- ) # Checksum for main script
- if logger.isEnabledFor(logging.DEBUG):
- logger.info(f"Checksum file path set: Script {SCRIPT_CHECKSUM_FILE}")
-except Exception as e:
- if logger.isEnabledFor(logging.DEBUG):
- logger.error(f"Error setting checksum file paths: {e}", exc_info=True)
+SCRIPT_CHECKSUM_FILE = (
+ APP_DIR / "seedpass_script_checksum.txt"
+) # Checksum for main script
+
+
+def initialize_app() -> None:
+ """Ensure the application directory exists."""
+ try:
+ APP_DIR.mkdir(exist_ok=True, parents=True)
+ if logger.isEnabledFor(logging.DEBUG):
+ logger.info(f"Application directory created at {APP_DIR}")
+ except Exception as exc:
+ if logger.isEnabledFor(logging.DEBUG):
+ logger.error(
+ f"Failed to create application directory: {exc}", exc_info=True
+ )
+
# -----------------------------------
# Password Generation Constants
diff --git a/src/main.py b/src/main.py
index bd4182a..5bc8a03 100644
--- a/src/main.py
+++ b/src/main.py
@@ -16,7 +16,7 @@ import traceback
from password_manager.manager import PasswordManager
from nostr.client import NostrClient
-from constants import INACTIVITY_TIMEOUT
+from constants import INACTIVITY_TIMEOUT, initialize_app
from utils.password_prompt import PasswordPromptError
from local_bip85.bip85 import Bip85Error
@@ -613,6 +613,7 @@ def display_menu(
if __name__ == "__main__":
# Configure logging with both file and console handlers
configure_logging()
+ initialize_app()
logger = logging.getLogger(__name__)
logger.info("Starting SeedPass Password Manager")
diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py
index 10dffcb..8819155 100644
--- a/src/password_manager/manager.py
+++ b/src/password_manager/manager.py
@@ -47,6 +47,7 @@ from constants import (
DEFAULT_PASSWORD_LENGTH,
INACTIVITY_TIMEOUT,
DEFAULT_SEED_BACKUP_FILENAME,
+ initialize_app,
)
import traceback
@@ -80,6 +81,7 @@ class PasswordManager:
def __init__(self) -> None:
"""Initialize the PasswordManager."""
+ initialize_app()
self.encryption_mode: EncryptionMode = EncryptionMode.SEED_ONLY
self.encryption_manager: Optional[EncryptionManager] = None
self.entry_manager: Optional[EntryManager] = None
From 47d9e9d8e4d34e00d097426f8c5ffad3ac1abeed Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Wed, 2 Jul 2025 22:16:18 -0400
Subject: [PATCH 37/43] Add migration to version 2 and update tests
---
src/password_manager/encryption.py | 2 +-
src/password_manager/entry_management.py | 31 +++++++++----------
src/password_manager/migrations.py | 15 ++++++++-
src/tests/test_backup_restore.py | 22 +++++++++----
src/tests/test_entry_add.py | 4 +--
.../test_entry_management_checksum_path.py | 4 +--
src/tests/test_index_import_export.py | 16 ++++++++--
src/tests/test_migrations.py | 7 +++--
.../test_password_unlock_after_change.py | 2 +-
src/tests/test_profile_management.py | 4 +--
10 files changed, 70 insertions(+), 37 deletions(-)
diff --git a/src/password_manager/encryption.py b/src/password_manager/encryption.py
index f63fcbf..cd4cd73 100644
--- a/src/password_manager/encryption.py
+++ b/src/password_manager/encryption.py
@@ -291,7 +291,7 @@ class EncryptionManager:
"yellow",
)
)
- return {"passwords": {}}
+ return {"entries": {}}
try:
decrypted_data = self.decrypt_file(relative_path)
diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py
index 26f4008..00c07c3 100644
--- a/src/password_manager/entry_management.py
+++ b/src/password_manager/entry_management.py
@@ -62,12 +62,12 @@ class EntryManager:
return data
except Exception as e:
logger.error(f"Failed to load index: {e}")
- return {"schema_version": LATEST_VERSION, "passwords": {}}
+ return {"schema_version": LATEST_VERSION, "entries": {}}
else:
logger.info(
f"Index file '{self.index_file}' not found. Initializing new password database."
)
- return {"schema_version": LATEST_VERSION, "passwords": {}}
+ return {"schema_version": LATEST_VERSION, "entries": {}}
def _save_index(self, data: Dict[str, Any]) -> None:
try:
@@ -85,8 +85,8 @@ class EntryManager:
"""
try:
data = self.vault.load_index()
- if "passwords" in data and isinstance(data["passwords"], dict):
- indices = [int(idx) for idx in data["passwords"].keys()]
+ if "entries" in data and isinstance(data["entries"], dict):
+ indices = [int(idx) for idx in data["entries"].keys()]
next_index = max(indices) + 1 if indices else 0
else:
next_index = 0
@@ -119,7 +119,8 @@ class EntryManager:
index = self.get_next_index()
data = self.vault.load_index()
- data["passwords"][str(index)] = {
+ data.setdefault("entries", {})
+ data["entries"][str(index)] = {
"website": website_name,
"length": length,
"username": username if username else "",
@@ -127,9 +128,7 @@ class EntryManager:
"blacklisted": blacklisted,
}
- logger.debug(
- f"Added entry at index {index}: {data['passwords'][str(index)]}"
- )
+ logger.debug(f"Added entry at index {index}: {data['entries'][str(index)]}")
self._save_index(data)
self.update_checksum()
@@ -169,7 +168,7 @@ class EntryManager:
"""
try:
data = self.vault.load_index()
- entry = data.get("passwords", {}).get(str(index))
+ entry = data.get("entries", {}).get(str(index))
if entry:
logger.debug(f"Retrieved entry at index {index}: {entry}")
@@ -205,7 +204,7 @@ class EntryManager:
"""
try:
data = self.vault.load_index()
- entry = data.get("passwords", {}).get(str(index))
+ entry = data.get("entries", {}).get(str(index))
if not entry:
logger.warning(
@@ -233,7 +232,7 @@ class EntryManager:
f"Updated blacklist status to '{blacklisted}' for index {index}."
)
- data["passwords"][str(index)] = entry
+ data["entries"][str(index)] = entry
logger.debug(f"Modified entry at index {index}: {entry}")
self._save_index(data)
@@ -259,15 +258,15 @@ class EntryManager:
"""
try:
data = self.vault.load_index()
- passwords = data.get("passwords", {})
+ entries_data = data.get("entries", {})
- if not passwords:
+ if not entries_data:
logger.info("No password entries found.")
print(colored("No password entries found.", "yellow"))
return []
entries = []
- for idx, entry in sorted(passwords.items(), key=lambda x: int(x[0])):
+ for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])):
entries.append(
(
int(idx),
@@ -302,8 +301,8 @@ class EntryManager:
"""
try:
data = self.vault.load_index()
- if "passwords" in data and str(index) in data["passwords"]:
- del data["passwords"][str(index)]
+ if "entries" in data and str(index) in data["entries"]:
+ del data["entries"][str(index)]
logger.debug(f"Deleted entry at index {index}.")
self.vault.save_index(data)
self.update_checksum()
diff --git a/src/password_manager/migrations.py b/src/password_manager/migrations.py
index 5984279..3e20295 100644
--- a/src/password_manager/migrations.py
+++ b/src/password_manager/migrations.py
@@ -26,7 +26,20 @@ def _v0_to_v1(data: dict) -> dict:
return data
-LATEST_VERSION = 1
+@migration(1)
+def _v1_to_v2(data: dict) -> dict:
+ passwords = data.pop("passwords", {})
+ entries = {}
+ for k, v in passwords.items():
+ v.setdefault("type", "password")
+ v.setdefault("notes", "")
+ entries[k] = v
+ data["entries"] = entries
+ data["schema_version"] = 2
+ return data
+
+
+LATEST_VERSION = 2
def apply_migrations(data: dict) -> dict:
diff --git a/src/tests/test_backup_restore.py b/src/tests/test_backup_restore.py
index 08e2f3e..dbc1a24 100644
--- a/src/tests/test_backup_restore.py
+++ b/src/tests/test_backup_restore.py
@@ -19,7 +19,12 @@ def test_backup_restore_workflow(monkeypatch):
index_file = fp_dir / "seedpass_passwords_db.json.enc"
- data1 = {"passwords": {"0": {"website": "a", "length": 10}}}
+ data1 = {
+ "schema_version": 2,
+ "entries": {
+ "0": {"website": "a", "length": 10, "type": "password", "notes": ""}
+ },
+ }
vault.save_index(data1)
os.utime(index_file, (1, 1))
@@ -29,7 +34,12 @@ def test_backup_restore_workflow(monkeypatch):
assert backup1.exists()
assert backup1.stat().st_mode & 0o777 == 0o600
- data2 = {"passwords": {"0": {"website": "b", "length": 12}}}
+ data2 = {
+ "schema_version": 2,
+ "entries": {
+ "0": {"website": "b", "length": 12, "type": "password", "notes": ""}
+ },
+ }
vault.save_index(data2)
os.utime(index_file, (2, 2))
@@ -39,13 +49,13 @@ def test_backup_restore_workflow(monkeypatch):
assert backup2.exists()
assert backup2.stat().st_mode & 0o777 == 0o600
- vault.save_index({"passwords": {"temp": {}}})
+ vault.save_index({"schema_version": 2, "entries": {"temp": {}}})
backup_mgr.restore_latest_backup()
- assert vault.load_index()["passwords"] == data2["passwords"]
+ assert vault.load_index()["entries"] == data2["entries"]
- vault.save_index({"passwords": {}})
+ vault.save_index({"schema_version": 2, "entries": {}})
backup_mgr.restore_backup_by_timestamp(1111)
- assert vault.load_index()["passwords"] == data1["passwords"]
+ assert vault.load_index()["entries"] == data1["entries"]
backup1.unlink()
current = vault.load_index()
diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py
index 30ebab4..08d53e4 100644
--- a/src/tests/test_entry_add.py
+++ b/src/tests/test_entry_add.py
@@ -26,5 +26,5 @@ def test_add_and_retrieve_entry():
}
data = enc_mgr.load_json_data(entry_mgr.index_file)
- assert str(index) in data.get("passwords", {})
- assert data["passwords"][str(index)] == entry
+ assert str(index) in data.get("entries", {})
+ assert data["entries"][str(index)] == entry
diff --git a/src/tests/test_entry_management_checksum_path.py b/src/tests/test_entry_management_checksum_path.py
index b1f6e13..fb48aee 100644
--- a/src/tests/test_entry_management_checksum_path.py
+++ b/src/tests/test_entry_management_checksum_path.py
@@ -16,7 +16,7 @@ def test_update_checksum_writes_to_expected_path():
entry_mgr = EntryManager(vault, tmp_path)
# create an empty index file
- vault.save_index({"passwords": {}})
+ vault.save_index({"entries": {}})
entry_mgr.update_checksum()
expected = tmp_path / "seedpass_passwords_db_checksum.txt"
@@ -29,7 +29,7 @@ def test_backup_index_file_creates_backup_in_directory():
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
entry_mgr = EntryManager(vault, tmp_path)
- vault.save_index({"passwords": {}})
+ vault.save_index({"entries": {}})
entry_mgr.backup_index_file()
backups = list(tmp_path.glob("passwords_db_backup_*.json.enc"))
diff --git a/src/tests/test_index_import_export.py b/src/tests/test_index_import_export.py
index 87ec285..fce78c2 100644
--- a/src/tests/test_index_import_export.py
+++ b/src/tests/test_index_import_export.py
@@ -30,17 +30,27 @@ def test_index_export_import_round_trip():
tmp = Path(td)
vault = setup_vault(tmp)
- original = {"passwords": {"0": {"website": "example"}}}
+ original = {
+ "schema_version": 2,
+ "entries": {"0": {"website": "example", "type": "password", "notes": ""}},
+ }
vault.save_index(original)
encrypted = vault.get_encrypted_index()
assert isinstance(encrypted, bytes)
- vault.save_index({"passwords": {"0": {"website": "changed"}}})
+ vault.save_index(
+ {
+ "schema_version": 2,
+ "entries": {
+ "0": {"website": "changed", "type": "password", "notes": ""}
+ },
+ }
+ )
vault.decrypt_and_save_index_from_nostr(encrypted)
loaded = vault.load_index()
- assert loaded["passwords"] == original["passwords"]
+ assert loaded["entries"] == original["entries"]
def test_get_encrypted_index_missing_file(tmp_path):
diff --git a/src/tests/test_migrations.py b/src/tests/test_migrations.py
index c212c2a..feb71aa 100644
--- a/src/tests/test_migrations.py
+++ b/src/tests/test_migrations.py
@@ -13,18 +13,19 @@ def setup(tmp_path: Path):
return enc_mgr, vault
-def test_migrate_v0_to_v1(tmp_path: Path):
+def test_migrate_v0_to_v2(tmp_path: Path):
enc_mgr, vault = setup(tmp_path)
legacy = {"passwords": {"0": {"website": "a", "length": 8}}}
enc_mgr.save_json_data(legacy)
data = vault.load_index()
assert data["schema_version"] == LATEST_VERSION
- assert data["passwords"] == legacy["passwords"]
+ expected_entry = {"website": "a", "length": 8, "type": "password", "notes": ""}
+ assert data["entries"]["0"] == expected_entry
def test_error_on_future_version(tmp_path: Path):
enc_mgr, vault = setup(tmp_path)
- future = {"schema_version": LATEST_VERSION + 1, "passwords": {}}
+ future = {"schema_version": LATEST_VERSION + 1, "entries": {}}
enc_mgr.save_json_data(future)
with pytest.raises(ValueError):
vault.load_index()
diff --git a/src/tests/test_password_unlock_after_change.py b/src/tests/test_password_unlock_after_change.py
index c548135..5fabc93 100644
--- a/src/tests/test_password_unlock_after_change.py
+++ b/src/tests/test_password_unlock_after_change.py
@@ -32,7 +32,7 @@ def test_password_change_and_unlock(monkeypatch):
entry_mgr = EntryManager(vault, fp)
cfg_mgr = ConfigManager(vault, fp)
- vault.save_index({"passwords": {}})
+ vault.save_index({"entries": {}})
cfg_mgr.save_config(
{
"relays": [],
diff --git a/src/tests/test_profile_management.py b/src/tests/test_profile_management.py
index de0635b..6aab635 100644
--- a/src/tests/test_profile_management.py
+++ b/src/tests/test_profile_management.py
@@ -58,7 +58,7 @@ def test_add_and_delete_entry(monkeypatch):
pm.entry_manager = entry_mgr
index = entry_mgr.add_entry("example.com", 12)
- assert str(index) in vault.load_index()["passwords"]
+ assert str(index) in vault.load_index()["entries"]
published = []
pm.nostr_client = SimpleNamespace(
@@ -73,5 +73,5 @@ def test_add_and_delete_entry(monkeypatch):
pm.delete_entry()
- assert str(index) not in vault.load_index()["passwords"]
+ assert str(index) not in vault.load_index()["entries"]
assert published
From 7fff164da72cfbfe79773a8e814a457bccdcb9c4 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Wed, 2 Jul 2025 22:28:56 -0400
Subject: [PATCH 38/43] Update entry manager for generic entries
---
src/password_manager/entry_management.py | 72 +++++++++++++++++++-----
src/tests/test_entry_add.py | 2 +
2 files changed, 60 insertions(+), 14 deletions(-)
diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py
index 00c07c3..911b854 100644
--- a/src/password_manager/entry_management.py
+++ b/src/password_manager/entry_management.py
@@ -28,6 +28,7 @@ from pathlib import Path
from termcolor import colored
from password_manager.migrations import LATEST_VERSION
+from password_manager.entry_types import EntryType
from password_manager.vault import Vault
from utils.file_lock import exclusive_lock
@@ -65,7 +66,7 @@ class EntryManager:
return {"schema_version": LATEST_VERSION, "entries": {}}
else:
logger.info(
- f"Index file '{self.index_file}' not found. Initializing new password database."
+ f"Index file '{self.index_file}' not found. Initializing new entries database."
)
return {"schema_version": LATEST_VERSION, "entries": {}}
@@ -79,7 +80,7 @@ class EntryManager:
def get_next_index(self) -> int:
"""
- Retrieves the next available index for a new password entry.
+ Retrieves the next available index for a new entry.
:return: The next index number as an integer.
"""
@@ -104,15 +105,17 @@ class EntryManager:
username: Optional[str] = None,
url: Optional[str] = None,
blacklisted: bool = False,
+ notes: str = "",
) -> int:
"""
- Adds a new password entry to the encrypted JSON index file.
+ Adds a new entry to the encrypted JSON index file.
:param website_name: The name of the website.
:param length: The desired length of the password.
:param username: (Optional) The username associated with the website.
:param url: (Optional) The URL of the website.
:param blacklisted: (Optional) Whether the password is blacklisted. Defaults to False.
+ :param notes: (Optional) Extra notes to attach to the entry.
:return: The assigned index of the new entry.
"""
try:
@@ -126,6 +129,8 @@ class EntryManager:
"username": username if username else "",
"url": url if url else "",
"blacklisted": blacklisted,
+ "type": EntryType.PASSWORD.value,
+ "notes": notes,
}
logger.debug(f"Added entry at index {index}: {data['entries'][str(index)]}")
@@ -144,6 +149,39 @@ class EntryManager:
print(colored(f"Error: Failed to add entry: {e}", "red"))
sys.exit(1)
+ def add_totp(self, notes: str = "") -> int:
+ """Placeholder for adding a TOTP entry."""
+ index = self.get_next_index()
+ data = self.vault.load_index()
+ data.setdefault("entries", {})
+ data["entries"][str(index)] = {"type": EntryType.TOTP.value, "notes": notes}
+ self._save_index(data)
+ self.update_checksum()
+ self.backup_index_file()
+ raise NotImplementedError("TOTP entry support not implemented yet")
+
+ def add_ssh_key(self, notes: str = "") -> int:
+ """Placeholder for adding an SSH key entry."""
+ index = self.get_next_index()
+ data = self.vault.load_index()
+ data.setdefault("entries", {})
+ data["entries"][str(index)] = {"type": EntryType.SSH.value, "notes": notes}
+ self._save_index(data)
+ self.update_checksum()
+ self.backup_index_file()
+ raise NotImplementedError("SSH key entry support not implemented yet")
+
+ def add_seed(self, notes: str = "") -> int:
+ """Placeholder for adding a seed entry."""
+ index = self.get_next_index()
+ data = self.vault.load_index()
+ data.setdefault("entries", {})
+ data["entries"][str(index)] = {"type": EntryType.SEED.value, "notes": notes}
+ self._save_index(data)
+ self.update_checksum()
+ self.backup_index_file()
+ raise NotImplementedError("Seed entry support not implemented yet")
+
def get_encrypted_index(self) -> Optional[bytes]:
"""
Retrieves the encrypted password index file's contents.
@@ -161,9 +199,9 @@ class EntryManager:
def retrieve_entry(self, index: int) -> Optional[Dict[str, Any]]:
"""
- Retrieves a password entry based on the provided index.
+ Retrieves an entry based on the provided index.
- :param index: The index number of the password entry.
+ :param index: The index number of the entry.
:return: A dictionary containing the entry details or None if not found.
"""
try:
@@ -193,14 +231,16 @@ class EntryManager:
username: Optional[str] = None,
url: Optional[str] = None,
blacklisted: Optional[bool] = None,
+ notes: Optional[str] = None,
) -> None:
"""
- Modifies an existing password entry based on the provided index and new values.
+ Modifies an existing entry based on the provided index and new values.
- :param index: The index number of the password entry to modify.
+ :param index: The index number of the entry to modify.
:param username: (Optional) The new username.
:param url: (Optional) The new URL.
:param blacklisted: (Optional) The new blacklist status.
+ :param notes: (Optional) New notes to attach to the entry.
"""
try:
data = self.vault.load_index()
@@ -232,6 +272,10 @@ class EntryManager:
f"Updated blacklist status to '{blacklisted}' for index {index}."
)
+ if notes is not None:
+ entry["notes"] = notes
+ logger.debug(f"Updated notes for index {index}.")
+
data["entries"][str(index)] = entry
logger.debug(f"Modified entry at index {index}: {entry}")
@@ -252,7 +296,7 @@ class EntryManager:
def list_entries(self) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]:
"""
- Lists all password entries in the index.
+ Lists all entries in the index.
:return: A list of tuples containing entry details: (index, website, username, url, blacklisted)
"""
@@ -261,8 +305,8 @@ class EntryManager:
entries_data = data.get("entries", {})
if not entries_data:
- logger.info("No password entries found.")
- print(colored("No password entries found.", "yellow"))
+ logger.info("No entries found.")
+ print(colored("No entries found.", "yellow"))
return []
entries = []
@@ -295,9 +339,9 @@ class EntryManager:
def delete_entry(self, index: int) -> None:
"""
- Deletes a password entry based on the provided index.
+ Deletes an entry based on the provided index.
- :param index: The index number of the password entry to delete.
+ :param index: The index number of the entry to delete.
"""
try:
data = self.vault.load_index()
@@ -424,7 +468,7 @@ class EntryManager:
def list_all_entries(self) -> None:
"""
- Displays all password entries in a formatted manner.
+ Displays all entries in a formatted manner.
"""
try:
entries = self.list_entries()
@@ -432,7 +476,7 @@ class EntryManager:
print(colored("No entries to display.", "yellow"))
return
- print(colored("\n[+] Listing All Password Entries:\n", "green"))
+ print(colored("\n[+] Listing All Entries:\n", "green"))
for entry in entries:
index, website, username, url, blacklisted = entry
print(colored(f"Index: {index}", "cyan"))
diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py
index 08d53e4..eac6db8 100644
--- a/src/tests/test_entry_add.py
+++ b/src/tests/test_entry_add.py
@@ -23,6 +23,8 @@ def test_add_and_retrieve_entry():
"username": "user",
"url": "",
"blacklisted": False,
+ "type": "password",
+ "notes": "",
}
data = enc_mgr.load_json_data(entry_mgr.index_file)
From 854766c983a7cbaa41bce1479bee9ccbc2caf6fc Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Wed, 2 Jul 2025 22:35:57 -0400
Subject: [PATCH 39/43] Add BIP85 derivation helpers and tests
---
src/password_manager/password_generation.py | 17 +++++++++++++++++
src/tests/test_bip85_vectors.py | 19 +++++++++++++++++++
2 files changed, 36 insertions(+)
diff --git a/src/password_manager/password_generation.py b/src/password_manager/password_generation.py
index af37684..670416c 100644
--- a/src/password_manager/password_generation.py
+++ b/src/password_manager/password_generation.py
@@ -19,6 +19,7 @@ import hashlib
import string
import random
import traceback
+import base64
from typing import Optional
from termcolor import colored
from pathlib import Path
@@ -332,3 +333,19 @@ class PasswordGenerator:
logger.error(f"Error ensuring password complexity: {e}", exc_info=True)
print(colored(f"Error: Failed to ensure password complexity: {e}", "red"))
raise
+
+
+def derive_totp_secret(bip85: BIP85, idx: int) -> str:
+ """Derive a TOTP secret for the given index using BIP85."""
+ entropy = bip85.derive_entropy(index=idx, bytes_len=10, app_no=2)
+ return base64.b32encode(entropy).decode("utf-8")
+
+
+def derive_ssh_key(bip85: BIP85, idx: int) -> bytes:
+ """Derive 32 bytes of entropy suitable for an SSH key."""
+ return bip85.derive_entropy(index=idx, bytes_len=32, app_no=32)
+
+
+def derive_seed_phrase(bip85: BIP85, idx: int, words: int = 24) -> str:
+ """Derive a new BIP39 seed phrase using BIP85."""
+ return bip85.derive_mnemonic(index=idx, words_num=words)
diff --git a/src/tests/test_bip85_vectors.py b/src/tests/test_bip85_vectors.py
index 1ef3a73..21f872b 100644
--- a/src/tests/test_bip85_vectors.py
+++ b/src/tests/test_bip85_vectors.py
@@ -5,6 +5,11 @@ import pytest
sys.path.append(str(Path(__file__).resolve().parents[1]))
from local_bip85.bip85 import BIP85, Bip85Error
+from password_manager.password_generation import (
+ derive_totp_secret,
+ derive_ssh_key,
+ derive_seed_phrase,
+)
MASTER_XPRV = "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb"
@@ -13,6 +18,8 @@ EXPECTED_12 = "girl mad pet galaxy egg matter matrix prison refuse sense ordinar
EXPECTED_24 = "puppy ocean match cereal symbol another shed magic wrap hammer bulb intact gadget divorce twin tonight reason outdoor destroy simple truth cigar social volcano"
EXPECTED_SYMM_KEY = "7040bb53104f27367f317558e78a994ada7296c6fde36a364e5baf206e502bb1"
+EXPECTED_TOTP_SECRET = "OBALWUYQJ4TTM7ZR"
+EXPECTED_SSH_KEY = "52405cd0dd21c5be78314a7c1a3c65ffd8d896536cc7dee3157db5824f0c92e2"
@pytest.fixture(scope="module")
@@ -32,6 +39,18 @@ def test_bip85_symmetric_key(bip85):
assert bip85.derive_symmetric_key(index=0).hex() == EXPECTED_SYMM_KEY
+def test_derive_totp_secret(bip85):
+ assert derive_totp_secret(bip85, 0) == EXPECTED_TOTP_SECRET
+
+
+def test_derive_ssh_key(bip85):
+ assert derive_ssh_key(bip85, 0).hex() == EXPECTED_SSH_KEY
+
+
+def test_derive_seed_phrase(bip85):
+ assert derive_seed_phrase(bip85, 0) == EXPECTED_24
+
+
def test_invalid_params(bip85):
with pytest.raises(Bip85Error):
bip85.derive_mnemonic(index=0, words_num=15)
From d589a14ddd954e1dc82d2a1c0bcb73321bda6538 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Wed, 2 Jul 2025 22:48:37 -0400
Subject: [PATCH 40/43] Prompt for notes when adding or modifying
---
src/password_manager/manager.py | 26 ++++++++++++++++++++++++--
src/tests/test_manager_workflow.py | 2 ++
2 files changed, 26 insertions(+), 2 deletions(-)
diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py
index 8819155..b0595e5 100644
--- a/src/password_manager/manager.py
+++ b/src/password_manager/manager.py
@@ -801,6 +801,7 @@ class PasswordManager:
username = input("Enter the username (optional): ").strip()
url = input("Enter the URL (optional): ").strip()
+ notes = input("Enter notes (optional): ").strip()
length_input = input(
f"Enter desired password length (default {DEFAULT_PASSWORD_LENGTH}): "
@@ -822,7 +823,12 @@ class PasswordManager:
# Add the entry to the index and get the assigned index
index = self.entry_manager.add_entry(
- website_name, length, username, url, blacklisted=False
+ website_name,
+ length,
+ username,
+ url,
+ blacklisted=False,
+ notes=notes,
)
# Mark database as dirty for background sync
@@ -881,6 +887,10 @@ class PasswordManager:
username = entry.get("username")
url = entry.get("url")
blacklisted = entry.get("blacklisted")
+ notes = entry.get("notes", "")
+ notes = entry.get("notes", "")
+ notes = entry.get("notes", "")
+ notes = entry.get("notes", "")
print(
colored(
@@ -947,6 +957,7 @@ class PasswordManager:
username = entry.get("username")
url = entry.get("url")
blacklisted = entry.get("blacklisted")
+ notes = entry.get("notes", "")
# Display current values
print(
@@ -996,9 +1007,20 @@ class PasswordManager:
)
new_blacklisted = blacklisted
+ new_notes = (
+ input(
+ f'Enter new notes (leave blank to keep "{notes or "N/A"}"): '
+ ).strip()
+ or notes
+ )
+
# Update the entry
self.entry_manager.modify_entry(
- index, new_username, new_url, new_blacklisted
+ index,
+ new_username,
+ new_url,
+ new_blacklisted,
+ new_notes,
)
# Mark database as dirty for background sync
diff --git a/src/tests/test_manager_workflow.py b/src/tests/test_manager_workflow.py
index 009c640..6b0833c 100644
--- a/src/tests/test_manager_workflow.py
+++ b/src/tests/test_manager_workflow.py
@@ -51,11 +51,13 @@ def test_manager_workflow(monkeypatch):
"", # username
"", # url
"", # length (default)
+ "", # notes
"0", # retrieve index
"0", # modify index
"user", # new username
"", # new url
"", # blacklist status
+ "", # new notes
]
)
monkeypatch.setattr("builtins.input", lambda *args, **kwargs: next(inputs))
From 609751bf663ba6190f188ef2e061235fe95de154 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Wed, 2 Jul 2025 23:09:10 -0400
Subject: [PATCH 41/43] Update tests for entries schema
---
src/password_manager/backup.py | 8 +++---
src/password_manager/encryption.py | 18 ++++++------
src/password_manager/entry_management.py | 6 ++--
src/password_manager/manager.py | 2 +-
src/password_manager/vault.py | 2 +-
src/tests/test_backup_restore.py | 6 ++--
src/tests/test_encryption_checksum.py | 4 +--
src/tests/test_encryption_files.py | 2 +-
src/tests/test_entry_add.py | 28 +++++++++++++++++++
.../test_entry_management_checksum_path.py | 4 +--
src/tests/test_manager_workflow.py | 6 ++--
src/tests/test_migrations.py | 15 ++++++++++
src/tests/test_password_properties.py | 3 ++
13 files changed, 75 insertions(+), 29 deletions(-)
diff --git a/src/password_manager/backup.py b/src/password_manager/backup.py
index c094ab3..5a9e5c8 100644
--- a/src/password_manager/backup.py
+++ b/src/password_manager/backup.py
@@ -35,7 +35,7 @@ class BackupManager:
timestamped filenames to facilitate easy identification and retrieval.
"""
- BACKUP_FILENAME_TEMPLATE = "passwords_db_backup_{timestamp}.json.enc"
+ BACKUP_FILENAME_TEMPLATE = "entries_db_backup_{timestamp}.json.enc"
def __init__(self, fingerprint_dir: Path):
"""
@@ -47,7 +47,7 @@ class BackupManager:
self.fingerprint_dir = fingerprint_dir
self.backup_dir = self.fingerprint_dir / "backups"
self.backup_dir.mkdir(parents=True, exist_ok=True)
- self.index_file = self.fingerprint_dir / "seedpass_passwords_db.json.enc"
+ self.index_file = self.fingerprint_dir / "seedpass_entries_db.json.enc"
logger.debug(
f"BackupManager initialized with backup directory at {self.backup_dir}"
)
@@ -79,7 +79,7 @@ class BackupManager:
def restore_latest_backup(self) -> None:
try:
backup_files = sorted(
- self.backup_dir.glob("passwords_db_backup_*.json.enc"),
+ self.backup_dir.glob("entries_db_backup_*.json.enc"),
key=lambda x: x.stat().st_mtime,
reverse=True,
)
@@ -112,7 +112,7 @@ class BackupManager:
def list_backups(self) -> None:
try:
backup_files = sorted(
- self.backup_dir.glob("passwords_db_backup_*.json.enc"),
+ self.backup_dir.glob("entries_db_backup_*.json.enc"),
key=lambda x: x.stat().st_mtime,
reverse=True,
)
diff --git a/src/password_manager/encryption.py b/src/password_manager/encryption.py
index cd4cd73..4175c19 100644
--- a/src/password_manager/encryption.py
+++ b/src/password_manager/encryption.py
@@ -246,10 +246,10 @@ class EncryptionManager:
:param data: The JSON data to save.
:param relative_path: The relative path within the fingerprint directory where data will be saved.
- Defaults to 'seedpass_passwords_db.json.enc'.
+ Defaults to 'seedpass_entries_db.json.enc'.
"""
if relative_path is None:
- relative_path = Path("seedpass_passwords_db.json.enc")
+ relative_path = Path("seedpass_entries_db.json.enc")
try:
json_data = json.dumps(data, indent=4).encode("utf-8")
self.encrypt_and_save_file(json_data, relative_path)
@@ -273,11 +273,11 @@ class EncryptionManager:
Decrypts and loads JSON data from the specified relative path within the fingerprint directory.
:param relative_path: The relative path within the fingerprint directory from which data will be loaded.
- Defaults to 'seedpass_passwords_db.json.enc'.
+ Defaults to 'seedpass_entries_db.json.enc'.
:return: The decrypted JSON data as a dictionary.
"""
if relative_path is None:
- relative_path = Path("seedpass_passwords_db.json.enc")
+ relative_path = Path("seedpass_entries_db.json.enc")
file_path = self.fingerprint_dir / relative_path
@@ -320,10 +320,10 @@ class EncryptionManager:
Updates the checksum file for the specified file within the fingerprint directory.
:param relative_path: The relative path within the fingerprint directory for which the checksum will be updated.
- Defaults to 'seedpass_passwords_db.json.enc'.
+ Defaults to 'seedpass_entries_db.json.enc'.
"""
if relative_path is None:
- relative_path = Path("seedpass_passwords_db.json.enc")
+ relative_path = Path("seedpass_entries_db.json.enc")
try:
file_path = self.fingerprint_dir / relative_path
logger.debug("Calculating checksum of the encrypted file bytes.")
@@ -368,7 +368,7 @@ class EncryptionManager:
:return: Encrypted data as bytes or None if the index file does not exist.
"""
try:
- relative_path = Path("seedpass_passwords_db.json.enc")
+ relative_path = Path("seedpass_entries_db.json.enc")
if not (self.fingerprint_dir / relative_path).exists():
logger.error(
f"Index file '{relative_path}' does not exist in '{self.fingerprint_dir}'."
@@ -407,10 +407,10 @@ class EncryptionManager:
:param encrypted_data: The encrypted data retrieved from Nostr.
:param relative_path: The relative path within the fingerprint directory to update.
- Defaults to 'seedpass_passwords_db.json.enc'.
+ Defaults to 'seedpass_entries_db.json.enc'.
"""
if relative_path is None:
- relative_path = Path("seedpass_passwords_db.json.enc")
+ relative_path = Path("seedpass_entries_db.json.enc")
try:
decrypted_data = self.decrypt_data(encrypted_data)
data = json.loads(decrypted_data.decode("utf-8"))
diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py
index 911b854..9c9fc16 100644
--- a/src/password_manager/entry_management.py
+++ b/src/password_manager/entry_management.py
@@ -50,8 +50,8 @@ class EntryManager:
self.fingerprint_dir = fingerprint_dir
# Use paths relative to the fingerprint directory
- self.index_file = self.fingerprint_dir / "seedpass_passwords_db.json.enc"
- self.checksum_file = self.fingerprint_dir / "seedpass_passwords_db_checksum.txt"
+ self.index_file = self.fingerprint_dir / "seedpass_entries_db.json.enc"
+ self.checksum_file = self.fingerprint_dir / "seedpass_entries_db_checksum.txt"
logger.debug(f"EntryManager initialized with index file at {self.index_file}")
@@ -410,7 +410,7 @@ class EntryManager:
return
timestamp = int(time.time())
- backup_filename = f"passwords_db_backup_{timestamp}.json.enc"
+ backup_filename = f"entries_db_backup_{timestamp}.json.enc"
backup_path = self.fingerprint_dir / backup_filename
with open(index_file_path, "rb") as original_file, open(
diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py
index b0595e5..444ffaf 100644
--- a/src/password_manager/manager.py
+++ b/src/password_manager/manager.py
@@ -769,7 +769,7 @@ class PasswordManager:
def sync_index_from_nostr_if_missing(self) -> None:
"""Retrieve the password database from Nostr if it doesn't exist locally."""
- index_file = self.fingerprint_dir / "seedpass_passwords_db.json.enc"
+ index_file = self.fingerprint_dir / "seedpass_entries_db.json.enc"
if index_file.exists():
return
try:
diff --git a/src/password_manager/vault.py b/src/password_manager/vault.py
index a3cfeef..78d8b99 100644
--- a/src/password_manager/vault.py
+++ b/src/password_manager/vault.py
@@ -10,7 +10,7 @@ from .encryption import EncryptionManager
class Vault:
"""Simple wrapper around :class:`EncryptionManager` for vault storage."""
- INDEX_FILENAME = "seedpass_passwords_db.json.enc"
+ INDEX_FILENAME = "seedpass_entries_db.json.enc"
CONFIG_FILENAME = "seedpass_config.json.enc"
def __init__(
diff --git a/src/tests/test_backup_restore.py b/src/tests/test_backup_restore.py
index dbc1a24..ac61b46 100644
--- a/src/tests/test_backup_restore.py
+++ b/src/tests/test_backup_restore.py
@@ -17,7 +17,7 @@ def test_backup_restore_workflow(monkeypatch):
vault, enc_mgr = create_vault(fp_dir, TEST_SEED, TEST_PASSWORD)
backup_mgr = BackupManager(fp_dir)
- index_file = fp_dir / "seedpass_passwords_db.json.enc"
+ index_file = fp_dir / "seedpass_entries_db.json.enc"
data1 = {
"schema_version": 2,
@@ -30,7 +30,7 @@ def test_backup_restore_workflow(monkeypatch):
monkeypatch.setattr(time, "time", lambda: 1111)
backup_mgr.create_backup()
- backup1 = fp_dir / "backups" / "passwords_db_backup_1111.json.enc"
+ backup1 = fp_dir / "backups" / "entries_db_backup_1111.json.enc"
assert backup1.exists()
assert backup1.stat().st_mode & 0o777 == 0o600
@@ -45,7 +45,7 @@ def test_backup_restore_workflow(monkeypatch):
monkeypatch.setattr(time, "time", lambda: 2222)
backup_mgr.create_backup()
- backup2 = fp_dir / "backups" / "passwords_db_backup_2222.json.enc"
+ backup2 = fp_dir / "backups" / "entries_db_backup_2222.json.enc"
assert backup2.exists()
assert backup2.stat().st_mode & 0o777 == 0o600
diff --git a/src/tests/test_encryption_checksum.py b/src/tests/test_encryption_checksum.py
index 0922e8d..127f503 100644
--- a/src/tests/test_encryption_checksum.py
+++ b/src/tests/test_encryption_checksum.py
@@ -21,8 +21,8 @@ def test_encryption_checksum_workflow():
manager.save_json_data(data)
manager.update_checksum()
- enc_file = tmp_path / "seedpass_passwords_db.json.enc"
- chk_file = tmp_path / "seedpass_passwords_db.json_checksum.txt"
+ enc_file = tmp_path / "seedpass_entries_db.json.enc"
+ chk_file = tmp_path / "seedpass_entries_db.json_checksum.txt"
checksum = chk_file.read_text().strip()
assert re.fullmatch(r"[0-9a-f]{64}", checksum)
diff --git a/src/tests/test_encryption_files.py b/src/tests/test_encryption_files.py
index 3b93e02..0b7ddbe 100644
--- a/src/tests/test_encryption_files.py
+++ b/src/tests/test_encryption_files.py
@@ -20,7 +20,7 @@ def test_json_save_and_load_round_trip():
loaded = manager.load_json_data()
assert loaded == data
- file_path = Path(tmpdir) / "seedpass_passwords_db.json.enc"
+ file_path = Path(tmpdir) / "seedpass_entries_db.json.enc"
raw = file_path.read_bytes()
assert raw != json.dumps(data, indent=4).encode("utf-8")
diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py
index eac6db8..fce73f4 100644
--- a/src/tests/test_entry_add.py
+++ b/src/tests/test_entry_add.py
@@ -1,6 +1,7 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
+import pytest
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1]))
@@ -30,3 +31,30 @@ def test_add_and_retrieve_entry():
data = enc_mgr.load_json_data(entry_mgr.index_file)
assert str(index) in data.get("entries", {})
assert data["entries"][str(index)] == entry
+
+
+@pytest.mark.parametrize(
+ "method, expected_type",
+ [
+ ("add_entry", "password"),
+ ("add_totp", "totp"),
+ ("add_ssh_key", "ssh"),
+ ("add_seed", "seed"),
+ ],
+)
+def test_round_trip_entry_types(method, expected_type):
+ with TemporaryDirectory() as tmpdir:
+ vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
+ entry_mgr = EntryManager(vault, Path(tmpdir))
+
+ if method == "add_entry":
+ index = entry_mgr.add_entry("example.com", 8)
+ else:
+ with pytest.raises(NotImplementedError):
+ getattr(entry_mgr, method)()
+ index = 0
+
+ entry = entry_mgr.retrieve_entry(index)
+ assert entry["type"] == expected_type
+ data = enc_mgr.load_json_data(entry_mgr.index_file)
+ assert data["entries"][str(index)]["type"] == expected_type
diff --git a/src/tests/test_entry_management_checksum_path.py b/src/tests/test_entry_management_checksum_path.py
index fb48aee..c002008 100644
--- a/src/tests/test_entry_management_checksum_path.py
+++ b/src/tests/test_entry_management_checksum_path.py
@@ -19,7 +19,7 @@ def test_update_checksum_writes_to_expected_path():
vault.save_index({"entries": {}})
entry_mgr.update_checksum()
- expected = tmp_path / "seedpass_passwords_db_checksum.txt"
+ expected = tmp_path / "seedpass_entries_db_checksum.txt"
assert expected.exists()
@@ -32,5 +32,5 @@ def test_backup_index_file_creates_backup_in_directory():
vault.save_index({"entries": {}})
entry_mgr.backup_index_file()
- backups = list(tmp_path.glob("passwords_db_backup_*.json.enc"))
+ backups = list(tmp_path.glob("entries_db_backup_*.json.enc"))
assert len(backups) == 1
diff --git a/src/tests/test_manager_workflow.py b/src/tests/test_manager_workflow.py
index 6b0833c..d78d43a 100644
--- a/src/tests/test_manager_workflow.py
+++ b/src/tests/test_manager_workflow.py
@@ -64,9 +64,9 @@ def test_manager_workflow(monkeypatch):
pm.handle_add_password()
assert pm.is_dirty is False
- backups = list(tmp_path.glob("passwords_db_backup_*.json.enc"))
+ backups = list(tmp_path.glob("entries_db_backup_*.json.enc"))
assert len(backups) == 1
- checksum_file = tmp_path / "seedpass_passwords_db_checksum.txt"
+ checksum_file = tmp_path / "seedpass_entries_db_checksum.txt"
assert checksum_file.exists()
checksum_after_add = checksum_file.read_text()
first_post = pm.nostr_client.published[-1]
@@ -79,7 +79,7 @@ def test_manager_workflow(monkeypatch):
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"))
+ backups_mod = list(backup_dir.glob("entries_db_backup_*.json.enc"))
assert backups_mod
checksum_after_modify = checksum_file.read_text()
assert checksum_after_modify != checksum_after_add
diff --git a/src/tests/test_migrations.py b/src/tests/test_migrations.py
index feb71aa..a1cd7e4 100644
--- a/src/tests/test_migrations.py
+++ b/src/tests/test_migrations.py
@@ -23,6 +23,21 @@ def test_migrate_v0_to_v2(tmp_path: Path):
assert data["entries"]["0"] == expected_entry
+def test_migrate_v1_to_v2(tmp_path: Path):
+ enc_mgr, vault = setup(tmp_path)
+ legacy = {"schema_version": 1, "passwords": {"0": {"website": "b", "length": 10}}}
+ enc_mgr.save_json_data(legacy)
+ data = vault.load_index()
+ assert data["schema_version"] == LATEST_VERSION
+ expected_entry = {
+ "website": "b",
+ "length": 10,
+ "type": "password",
+ "notes": "",
+ }
+ assert data["entries"]["0"] == expected_entry
+
+
def test_error_on_future_version(tmp_path: Path):
enc_mgr, vault = setup(tmp_path)
future = {"schema_version": LATEST_VERSION + 1, "entries": {}}
diff --git a/src/tests/test_password_properties.py b/src/tests/test_password_properties.py
index aa48621..f51dd5f 100644
--- a/src/tests/test_password_properties.py
+++ b/src/tests/test_password_properties.py
@@ -6,6 +6,7 @@ from hypothesis import given, strategies as st, settings
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.password_generation import PasswordGenerator
+from password_manager.entry_types import EntryType
class DummyEnc:
@@ -32,8 +33,10 @@ def make_generator():
@settings(deadline=None)
def test_password_properties(length, index):
pg = make_generator()
+ entry_type = EntryType.PASSWORD.value
pw1 = pg.generate_password(length=length, index=index)
pw2 = pg.generate_password(length=length, index=index)
+ assert entry_type == "password"
assert pw1 == pw2
assert len(pw1) == length
From a6cffd200797ae22ca6dd1513d55d0d5a596ca2c Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Wed, 2 Jul 2025 23:22:03 -0400
Subject: [PATCH 42/43] Update docs for entries schema
---
README.md | 18 ++++++++++++++++++
docs/json_entries.md | 36 +++++++++++++++++++++++++++---------
2 files changed, 45 insertions(+), 9 deletions(-)
diff --git a/README.md b/README.md
index f2bd9f7..97456e7 100644
--- a/README.md
+++ b/README.md
@@ -118,6 +118,24 @@ seedpass export --file "~/seedpass_backup.json"
seedpass import --file "~/seedpass_backup.json"
```
+### Vault JSON Layout
+
+The encrypted index file `seedpass_entries_db.json.enc` begins with `schema_version` `2` and stores an `entries` map keyed by entry numbers.
+
+```json
+{
+ "schema_version": 2,
+ "entries": {
+ "0": {
+ "website": "example.com",
+ "length": 8,
+ "type": "password",
+ "notes": ""
+ }
+ }
+}
+```
+
## Usage
diff --git a/docs/json_entries.md b/docs/json_entries.md
index 295ce48..1225f90 100644
--- a/docs/json_entries.md
+++ b/docs/json_entries.md
@@ -33,6 +33,26 @@
---
+## Index File Format
+
+All entries belonging to a seed profile are summarized in an encrypted file named `seedpass_entries_db.json.enc`. This index starts with `schema_version` `2` and contains an `entries` object keyed by entry numbers.
+
+```json
+{
+ "schema_version": 2,
+ "entries": {
+ "0": {
+ "website": "example.com",
+ "length": 8,
+ "type": "password",
+ "notes": ""
+ }
+ }
+}
+```
+
+---
+
## JSON Schema for Individual Entries
Each SeedPass entry is stored as an individual JSON file, promoting isolated management and easy synchronization with Nostr. This structure supports diverse entry types (`kind`) and allows for future expansions.
@@ -444,9 +464,8 @@ All backups are organized based on fingerprints, ensuring that each seed's data
│ │ ├── entry_1_v1.json
│ │ └── ...
│ ├── parent_seed.enc
-│ ├── seedpass_passwords_checksum.txt
-│ ├── seedpass_passwords_db_checksum.txt
-│ └── seedpass_passwords_db.json
+│ ├── seedpass_entries_db_checksum.txt
+│ └── seedpass_entries_db.json
├── b5c6d7e8/
│ ├── entries/
│ │ ├── entry_0.json
@@ -458,9 +477,8 @@ All backups are organized based on fingerprints, ensuring that each seed's data
│ │ ├── entry_1_v1.json
│ │ └── ...
│ ├── parent_seed.enc
-│ ├── seedpass_passwords_checksum.txt
-│ ├── seedpass_passwords_db_checksum.txt
-│ └── seedpass_passwords_db.json
+│ ├── seedpass_entries_db_checksum.txt
+│ └── seedpass_entries_db.json
└── ...
```
@@ -498,9 +516,9 @@ seedpass rollback --fingerprint a1b2c3d4 --file entry_0_v1.json
│ │ ├── entry_1_v1.json
│ │ └── ...
│ ├── parent_seed.enc
-│ ├── seedpass_passwords_checksum.txt
-│ ├── seedpass_passwords_db_checksum.txt
-│ └── seedpass_passwords_db.json
+│ ├── seedpass_script_checksum.txt
+│ ├── seedpass_entries_db_checksum.txt
+│ └── seedpass_entries_db.json
├── ...
```
From d270236a41e51c79fbe29c248a7431a10d8449da Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Wed, 2 Jul 2025 23:30:26 -0400
Subject: [PATCH 43/43] Add timed input for inactivity and tests
---
src/main.py | 11 ++++++-
src/tests/test_auto_sync.py | 2 +-
src/tests/test_cli_invalid_input.py | 7 +++--
src/tests/test_inactivity_lock.py | 46 ++++++++++++++++++++++++++++-
src/utils/__init__.py | 2 ++
src/utils/input_utils.py | 19 ++++++++++++
6 files changed, 81 insertions(+), 6 deletions(-)
create mode 100644 src/utils/input_utils.py
diff --git a/src/main.py b/src/main.py
index 5bc8a03..01eeb95 100644
--- a/src/main.py
+++ b/src/main.py
@@ -18,6 +18,7 @@ from password_manager.manager import PasswordManager
from nostr.client import NostrClient
from constants import INACTIVITY_TIMEOUT, initialize_app
from utils.password_prompt import PasswordPromptError
+from utils import timed_input
from local_bip85.bip85 import Bip85Error
@@ -568,7 +569,15 @@ def display_menu(
for handler in logging.getLogger().handlers:
handler.flush()
print(colored(menu, "cyan"))
- choice = input("Enter your choice (1-5): ").strip()
+ try:
+ choice = timed_input(
+ "Enter your choice (1-5): ", inactivity_timeout
+ ).strip()
+ except TimeoutError:
+ print(colored("Session timed out. Vault locked.", "yellow"))
+ password_manager.lock_vault()
+ password_manager.unlock_vault()
+ continue
password_manager.update_activity()
if not choice:
print(
diff --git a/src/tests/test_auto_sync.py b/src/tests/test_auto_sync.py
index 322e07c..1207e10 100644
--- a/src/tests/test_auto_sync.py
+++ b/src/tests/test_auto_sync.py
@@ -31,7 +31,7 @@ def test_auto_sync_triggers_post(monkeypatch):
called = True
monkeypatch.setattr(main, "handle_post_to_nostr", fake_post)
- monkeypatch.setattr("builtins.input", lambda _: "5")
+ monkeypatch.setattr(main, "timed_input", lambda *_: "5")
with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=0.1)
diff --git a/src/tests/test_cli_invalid_input.py b/src/tests/test_cli_invalid_input.py
index d4f17b1..cb65f0d 100644
--- a/src/tests/test_cli_invalid_input.py
+++ b/src/tests/test_cli_invalid_input.py
@@ -52,7 +52,7 @@ def test_empty_and_non_numeric_choice(monkeypatch, capsys):
called = {"add": False, "retrieve": False, "modify": False}
pm, _ = _make_pm(called)
inputs = iter(["", "abc", "5"])
- monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
+ monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000)
out = capsys.readouterr().out
@@ -65,7 +65,7 @@ def test_out_of_range_menu(monkeypatch, capsys):
called = {"add": False, "retrieve": False, "modify": False}
pm, _ = _make_pm(called)
inputs = iter(["9", "5"])
- monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
+ monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000)
out = capsys.readouterr().out
@@ -77,6 +77,7 @@ def test_invalid_add_entry_submenu(monkeypatch, capsys):
called = {"add": False, "retrieve": False, "modify": False}
pm, _ = _make_pm(called)
inputs = iter(["1", "3", "2", "5"])
+ monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000)
@@ -90,7 +91,7 @@ def test_inactivity_timeout_loop(monkeypatch, capsys):
pm, locked = _make_pm(called)
pm.last_activity = 0
monkeypatch.setattr(time, "time", lambda: 100.0)
- monkeypatch.setattr("builtins.input", lambda *_: "5")
+ monkeypatch.setattr(main, "timed_input", lambda *_: "5")
with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1)
out = capsys.readouterr().out
diff --git a/src/tests/test_inactivity_lock.py b/src/tests/test_inactivity_lock.py
index 5c6acb1..f819944 100644
--- a/src/tests/test_inactivity_lock.py
+++ b/src/tests/test_inactivity_lock.py
@@ -36,10 +36,54 @@ def test_inactivity_triggers_lock(monkeypatch):
unlock_vault=unlock_vault,
)
- monkeypatch.setattr("builtins.input", lambda _: "5")
+ monkeypatch.setattr(main, "timed_input", lambda *_: "5")
with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1)
assert locked["locked"]
assert locked["unlocked"]
+
+
+def test_input_timeout_triggers_lock(monkeypatch):
+ """Ensure locking occurs if no input is provided before timeout."""
+ locked = {"locked": 0, "unlocked": 0}
+
+ def update_activity():
+ pm.last_activity = time.time()
+
+ def lock_vault():
+ locked["locked"] += 1
+
+ def unlock_vault():
+ locked["unlocked"] += 1
+ update_activity()
+
+ pm = SimpleNamespace(
+ is_dirty=False,
+ last_update=time.time(),
+ last_activity=time.time(),
+ nostr_client=SimpleNamespace(close_client_pool=lambda: None),
+ handle_add_password=lambda: None,
+ handle_retrieve_entry=lambda: None,
+ handle_modify_entry=lambda: None,
+ update_activity=update_activity,
+ lock_vault=lock_vault,
+ unlock_vault=unlock_vault,
+ )
+
+ responses = iter([TimeoutError(), "5"])
+
+ def fake_input(*_args, **_kwargs):
+ val = next(responses)
+ if isinstance(val, Exception):
+ raise val
+ return val
+
+ monkeypatch.setattr(main, "timed_input", fake_input)
+
+ with pytest.raises(SystemExit):
+ main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1)
+
+ assert locked["locked"] == 1
+ assert locked["unlocked"] == 1
diff --git a/src/utils/__init__.py b/src/utils/__init__.py
index eb5ba69..7ea671d 100644
--- a/src/utils/__init__.py
+++ b/src/utils/__init__.py
@@ -21,6 +21,7 @@ try:
canonical_json_dumps,
)
from .password_prompt import prompt_for_password
+ from .input_utils import timed_input
if logger.isEnabledFor(logging.DEBUG):
logger.info("Modules imported successfully.")
@@ -41,4 +42,5 @@ __all__ = [
"exclusive_lock",
"shared_lock",
"prompt_for_password",
+ "timed_input",
]
diff --git a/src/utils/input_utils.py b/src/utils/input_utils.py
new file mode 100644
index 0000000..a9e58b3
--- /dev/null
+++ b/src/utils/input_utils.py
@@ -0,0 +1,19 @@
+import sys
+import select
+import io
+from typing import Optional
+
+
+def timed_input(prompt: str, timeout: Optional[float]) -> str:
+ """Read input from the user with a timeout."""
+ print(prompt, end="", flush=True)
+ if timeout is None or timeout <= 0:
+ return sys.stdin.readline().strip()
+ try:
+ sys.stdin.fileno()
+ except (AttributeError, io.UnsupportedOperation):
+ return input().strip()
+ ready, _, _ = select.select([sys.stdin], [], [], timeout)
+ if ready:
+ return sys.stdin.readline().strip()
+ raise TimeoutError("input timed out")