diff --git a/README.md b/README.md index c86c184..0f63233 100644 --- a/README.md +++ b/README.md @@ -473,7 +473,9 @@ subfolder (or adjust `APP_DIR` in `constants.py`) if you want to load it with the main application. The fingerprint is printed after creation and the encrypted index is published to Nostr. Use that same seed phrase to load SeedPass. The app checks Nostr on startup and pulls any newer snapshot so your -vault stays in sync across machines. +vault stays in sync across machines. If no snapshot exists or the download +cannot be decrypted (for example when using a brand-new seed), SeedPass +automatically initializes an empty index instead of exiting. ### Automatically Updating the Script Checksum diff --git a/src/password_manager/encryption.py b/src/password_manager/encryption.py index 38da332..ae21416 100644 --- a/src/password_manager/encryption.py +++ b/src/password_manager/encryption.py @@ -223,15 +223,28 @@ class EncryptionManager: return fh.read() def decrypt_and_save_index_from_nostr( - self, encrypted_data: bytes, relative_path: Optional[Path] = None - ) -> None: - """Decrypts data from Nostr and saves it, automatically using the new format.""" + self, + encrypted_data: bytes, + relative_path: Optional[Path] = None, + *, + strict: bool = True, + ) -> bool: + """Decrypts data from Nostr and saves it. + + Parameters + ---------- + encrypted_data: + The payload downloaded from Nostr. + relative_path: + Destination filename under the profile directory. + strict: + When ``True`` (default) re-raise any decryption error. When ``False`` + return ``False`` if decryption fails. + """ if relative_path is None: relative_path = Path("seedpass_entries_db.json.enc") try: - decrypted_data = self.decrypt_data( - encrypted_data - ) # This now handles both formats + decrypted_data = self.decrypt_data(encrypted_data) if USE_ORJSON: data = json_lib.loads(decrypted_data) else: @@ -240,18 +253,22 @@ class EncryptionManager: self.update_checksum(relative_path) logger.info("Index file from Nostr was processed and saved successfully.") print(colored("Index file updated from Nostr successfully.", "green")) - except Exception as e: - logger.error( - f"Failed to decrypt and save data from Nostr: {e}", - exc_info=True, - ) - print( - colored( - f"Error: Failed to decrypt and save data from Nostr: {e}", - "red", + return True + except Exception as e: # pragma: no cover - error handling + if strict: + logger.error( + f"Failed to decrypt and save data from Nostr: {e}", + exc_info=True, ) - ) - raise + print( + colored( + f"Error: Failed to decrypt and save data from Nostr: {e}", + "red", + ) + ) + raise + logger.warning(f"Failed to decrypt index from Nostr: {e}") + return False def update_checksum(self, relative_path: Optional[Path] = None) -> None: """Updates the checksum file for the specified file.""" diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 517ac4a..cf457a2 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1103,8 +1103,10 @@ class PasswordManager: encrypted = deltas[-1] current = self.vault.get_encrypted_index() if current != encrypted: - self.vault.decrypt_and_save_index_from_nostr(encrypted) - logger.info("Local database synchronized from Nostr.") + if self.vault.decrypt_and_save_index_from_nostr( + encrypted, strict=False + ): + logger.info("Local database synchronized from Nostr.") except Exception as e: logger.warning(f"Unable to sync index from Nostr: {e}") finally: @@ -1195,14 +1197,12 @@ class PasswordManager: deltas = asyncio.run(self.nostr_client.fetch_deltas_since(version)) if deltas: encrypted = deltas[-1] - try: - self.vault.decrypt_and_save_index_from_nostr(encrypted) + success = self.vault.decrypt_and_save_index_from_nostr( + encrypted, strict=False + ) + if success: logger.info("Initialized local database from Nostr.") have_data = True - except Exception as err: - logger.warning( - f"Failed to decrypt Nostr data: {err}; treating as new account." - ) except Exception as e: logger.warning(f"Unable to sync index from Nostr: {e}") diff --git a/src/password_manager/vault.py b/src/password_manager/vault.py index e5fe002..93667c1 100644 --- a/src/password_manager/vault.py +++ b/src/password_manager/vault.py @@ -60,9 +60,13 @@ class Vault: """Return the encrypted index bytes if present.""" return self.encryption_manager.get_encrypted_index() - def decrypt_and_save_index_from_nostr(self, encrypted_data: bytes) -> None: + def decrypt_and_save_index_from_nostr( + self, encrypted_data: bytes, *, strict: bool = True + ) -> bool: """Decrypt Nostr payload and overwrite the local index.""" - self.encryption_manager.decrypt_and_save_index_from_nostr(encrypted_data) + return self.encryption_manager.decrypt_and_save_index_from_nostr( + encrypted_data, strict=strict + ) # ----- Config helpers ----- def load_config(self) -> dict: diff --git a/src/tests/test_index_import_export.py b/src/tests/test_index_import_export.py index ea86dd3..04e3194 100644 --- a/src/tests/test_index_import_export.py +++ b/src/tests/test_index_import_export.py @@ -63,7 +63,7 @@ def test_index_export_import_round_trip(): }, } ) - vault.decrypt_and_save_index_from_nostr(encrypted) + assert vault.decrypt_and_save_index_from_nostr(encrypted) loaded = vault.load_index() assert loaded["entries"] == original["entries"] diff --git a/src/tests/test_profiles.py b/src/tests/test_profiles.py index c6cf5ec..7b70d5c 100644 --- a/src/tests/test_profiles.py +++ b/src/tests/test_profiles.py @@ -6,6 +6,9 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from utils.fingerprint_manager import FingerprintManager from password_manager.manager import PasswordManager, EncryptionMode +from helpers import create_vault, dummy_nostr_client +import gzip +from nostr.backup_models import Manifest, ChunkMeta VALID_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" @@ -51,3 +54,33 @@ def test_add_and_switch_fingerprint(monkeypatch): assert pm.current_fingerprint == fingerprint assert fm.current_fingerprint == fingerprint assert pm.fingerprint_dir == expected_dir + + +def test_sync_index_missing_bad_data(monkeypatch, dummy_nostr_client): + client, _relay = dummy_nostr_client + with TemporaryDirectory() as tmpdir: + dir_path = Path(tmpdir) + vault, _enc = create_vault(dir_path) + + pm = PasswordManager.__new__(PasswordManager) + pm.fingerprint_dir = dir_path + pm.vault = vault + pm.nostr_client = client + pm.sync_vault = lambda *a, **k: None + + manifest = Manifest( + ver=1, + algo="aes-gcm", + chunks=[ChunkMeta(id="c0", size=1, hash="00")], + delta_since=None, + ) + monkeypatch.setattr( + client, + "fetch_latest_snapshot", + lambda: (manifest, [gzip.compress(b"garbage")]), + ) + monkeypatch.setattr(client, "fetch_deltas_since", lambda *_a, **_k: []) + + pm.sync_index_from_nostr_if_missing() + data = pm.vault.load_index() + assert data["entries"] == {}