Merge pull request #149 from PR0M3TH3AN/codex/refactor-sync-and-vault-update-logic

Implement vault sync with Nostr snapshots
This commit is contained in:
thePR0M3TH3AN
2025-07-02 16:24:11 -04:00
committed by GitHub
7 changed files with 84 additions and 70 deletions

View File

@@ -7,6 +7,8 @@ import signal
import getpass import getpass
import time import time
import argparse import argparse
import asyncio
import gzip
import tomli import tomli
from colorama import init as colorama_init from colorama import init as colorama_init
from termcolor import colored from termcolor import colored
@@ -225,23 +227,13 @@ def handle_post_to_nostr(
Handles the action of posting the encrypted password index to Nostr. Handles the action of posting the encrypted password index to Nostr.
""" """
try: try:
# Get the encrypted data from the index file success = password_manager.sync_vault(alt_summary=alt_summary)
encrypted_data = password_manager.get_encrypted_data() if success:
if encrypted_data: print(colored("\N{WHITE HEAVY CHECK MARK} Sync complete.", "green"))
# Post to Nostr logging.info("Encrypted index posted to Nostr successfully.")
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.")
else: else:
print(colored("No data available to post.", "yellow")) print(colored("\N{CROSS MARK} Sync failed…", "red"))
logging.warning("No data available to post to Nostr.") logging.error("Failed to post encrypted index to Nostr.")
except Exception as e: except Exception as e:
logging.error(f"Failed to post to Nostr: {e}", exc_info=True) logging.error(f"Failed to post to Nostr: {e}", exc_info=True)
print(colored(f"Error: Failed to post to Nostr: {e}", "red")) 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. Handles the action of retrieving the encrypted password index from Nostr.
""" """
try: try:
# Use the Nostr client from the password_manager result = asyncio.run(password_manager.nostr_client.fetch_latest_snapshot())
encrypted_data = password_manager.nostr_client.retrieve_json_from_nostr_sync() if result:
if encrypted_data: manifest, chunks = result
# Decrypt and save the index 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( password_manager.encryption_manager.decrypt_and_save_index_from_nostr(
encrypted_data encrypted
) )
print(colored("Encrypted index retrieved and saved successfully.", "green")) print(colored("Encrypted index retrieved and saved successfully.", "green"))
logging.info("Encrypted index retrieved and saved successfully from Nostr.") logging.info("Encrypted index retrieved and saved successfully from Nostr.")

View File

@@ -55,6 +55,8 @@ from constants import (
) )
import traceback import traceback
import asyncio
import gzip
import bcrypt import bcrypt
from pathlib import Path from pathlib import Path
@@ -812,8 +814,20 @@ class PasswordManager:
if index_file.exists(): if index_file.exists():
return return
try: try:
encrypted = self.nostr_client.retrieve_json_from_nostr_sync() result = asyncio.run(self.nostr_client.fetch_latest_snapshot())
if encrypted: 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) self.vault.decrypt_and_save_index_from_nostr(encrypted)
logger.info("Initialized local database from Nostr.") logger.info("Initialized local database from Nostr.")
except Exception as e: except Exception as e:
@@ -871,12 +885,8 @@ class PasswordManager:
# Automatically push the updated encrypted index to Nostr so the # Automatically push the updated encrypted index to Nostr so the
# latest changes are backed up remotely. # latest changes are backed up remotely.
try: try:
encrypted_data = self.get_encrypted_data() self.sync_vault()
if encrypted_data: logging.info("Encrypted index posted to Nostr after entry addition.")
self.nostr_client.publish_json_to_nostr(encrypted_data)
logging.info(
"Encrypted index posted to Nostr after entry addition."
)
except Exception as nostr_error: except Exception as nostr_error:
logging.error( logging.error(
f"Failed to post updated index to Nostr: {nostr_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. # Push the updated index to Nostr so changes are backed up.
try: try:
encrypted_data = self.get_encrypted_data() self.sync_vault()
if encrypted_data: logging.info(
self.nostr_client.publish_json_to_nostr(encrypted_data) "Encrypted index posted to Nostr after entry modification."
logging.info( )
"Encrypted index posted to Nostr after entry modification."
)
except Exception as nostr_error: except Exception as nostr_error:
logging.error( logging.error(
f"Failed to post updated index to Nostr: {nostr_error}", f"Failed to post updated index to Nostr: {nostr_error}",
@@ -1081,12 +1089,8 @@ class PasswordManager:
# Push updated index to Nostr after deletion # Push updated index to Nostr after deletion
try: try:
encrypted_data = self.get_encrypted_data() self.sync_vault()
if encrypted_data: logging.info("Encrypted index posted to Nostr after entry deletion.")
self.nostr_client.publish_json_to_nostr(encrypted_data)
logging.info(
"Encrypted index posted to Nostr after entry deletion."
)
except Exception as nostr_error: except Exception as nostr_error:
logging.error( logging.error(
f"Failed to post updated index to Nostr: {nostr_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 # Re-raise the exception to inform the calling function of the failure
raise 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: def backup_database(self) -> None:
""" """
Creates a backup of the encrypted JSON index file. 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 # Push a fresh backup to Nostr so the newly encrypted index is
# stored remotely. Include a tag to mark the password change. # stored remotely. Include a tag to mark the password change.
try: try:
encrypted_data = self.get_encrypted_data() summary = f"password-change-{int(time.time())}"
if encrypted_data: self.sync_vault(alt_summary=summary)
summary = f"password-change-{int(time.time())}"
self.nostr_client.publish_json_to_nostr(
encrypted_data,
alt_summary=summary,
)
except Exception as nostr_error: except Exception as nostr_error:
logging.error( logging.error(
f"Failed to post updated index to Nostr after password change: {nostr_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")) print(colored("Encryption mode changed successfully.", "green"))
try: try:
encrypted_data = self.get_encrypted_data() summary = f"mode-change-{int(time.time())}"
if encrypted_data: self.sync_vault(alt_summary=summary)
summary = f"mode-change-{int(time.time())}"
self.nostr_client.publish_json_to_nostr(
encrypted_data,
alt_summary=summary,
)
except Exception as nostr_error: except Exception as nostr_error:
logging.error( logging.error(
f"Failed to post updated index to Nostr after encryption mode change: {nostr_error}" f"Failed to post updated index to Nostr after encryption mode change: {nostr_error}"

View File

@@ -8,6 +8,7 @@ import json
import logging import logging
import os import os
import time import time
import asyncio
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
@@ -103,7 +104,7 @@ def export_backup(
os.chmod(enc_file, 0o600) os.chmod(enc_file, 0o600)
try: try:
client = NostrClient(vault.encryption_manager, vault.fingerprint_dir.name) client = NostrClient(vault.encryption_manager, vault.fingerprint_dir.name)
client.publish_json_to_nostr(encrypted) asyncio.run(client.publish_snapshot(encrypted))
except Exception: except Exception:
logger.error("Failed to publish backup via Nostr", exc_info=True) logger.error("Failed to publish backup via Nostr", exc_info=True)

View File

@@ -2,7 +2,7 @@ import sys
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch, AsyncMock
from helpers import create_vault, TEST_SEED, TEST_PASSWORD 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: with patch("password_manager.manager.NostrClient") as MockClient:
mock = MockClient.return_value mock = MockClient.return_value
mock.publish_snapshot = AsyncMock(return_value=None)
pm.nostr_client = mock pm.nostr_client = mock
pm.change_encryption_mode(EncryptionMode.SEED_PLUS_PW) 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.encryption_mode is EncryptionMode.SEED_PLUS_PW
assert pm.password_generator.encryption_manager is pm.encryption_manager assert pm.password_generator.encryption_manager is pm.encryption_manager

View File

@@ -60,7 +60,7 @@ def test_manager_workflow(monkeypatch):
monkeypatch.setattr("builtins.input", lambda *args, **kwargs: next(inputs)) monkeypatch.setattr("builtins.input", lambda *args, **kwargs: next(inputs))
pm.handle_add_password() 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")) backups = list(tmp_path.glob("passwords_db_backup_*.json.enc"))
assert len(backups) == 1 assert len(backups) == 1
checksum_file = tmp_path / "seedpass_passwords_db_checksum.txt" checksum_file = tmp_path / "seedpass_passwords_db_checksum.txt"
@@ -73,7 +73,7 @@ def test_manager_workflow(monkeypatch):
assert pm.is_dirty is False assert pm.is_dirty is False
pm.handle_modify_entry() pm.handle_modify_entry()
assert pm.is_dirty is True assert pm.is_dirty is False
pm.backup_manager.create_backup() pm.backup_manager.create_backup()
backup_dir = tmp_path / "backups" backup_dir = tmp_path / "backups"
backups_mod = list(backup_dir.glob("passwords_db_backup_*.json.enc")) backups_mod = list(backup_dir.glob("passwords_db_backup_*.json.enc"))

View File

@@ -2,7 +2,7 @@ import sys
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch, AsyncMock
from helpers import create_vault, TEST_SEED, TEST_PASSWORD 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: with patch("password_manager.manager.NostrClient") as MockClient:
mock_instance = MockClient.return_value mock_instance = MockClient.return_value
mock_instance.publish_snapshot = AsyncMock(return_value=None)
pm.nostr_client = mock_instance pm.nostr_client = mock_instance
pm.change_password() pm.change_password()
mock_instance.publish_json_to_nostr.assert_called_once() mock_instance.publish_snapshot.assert_called_once()

View File

@@ -9,10 +9,7 @@ import main
def test_handle_post_success(capsys): def test_handle_post_success(capsys):
pm = SimpleNamespace( pm = SimpleNamespace(
get_encrypted_data=lambda: b"data", sync_vault=lambda alt_summary=None: True,
nostr_client=SimpleNamespace(
publish_json_to_nostr=lambda data, alt_summary=None: True
),
) )
main.handle_post_to_nostr(pm) main.handle_post_to_nostr(pm)
out = capsys.readouterr().out out = capsys.readouterr().out
@@ -21,10 +18,7 @@ def test_handle_post_success(capsys):
def test_handle_post_failure(capsys): def test_handle_post_failure(capsys):
pm = SimpleNamespace( pm = SimpleNamespace(
get_encrypted_data=lambda: b"data", sync_vault=lambda alt_summary=None: False,
nostr_client=SimpleNamespace(
publish_json_to_nostr=lambda data, alt_summary=None: False
),
) )
main.handle_post_to_nostr(pm) main.handle_post_to_nostr(pm)
out = capsys.readouterr().out out = capsys.readouterr().out