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] 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