Add vault sync and update Nostr restore

This commit is contained in:
thePR0M3TH3AN
2025-07-02 16:22:57 -04:00
parent ca88ccce57
commit 678cdbc5e6
7 changed files with 84 additions and 70 deletions

View File

@@ -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.")

View File

@@ -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}"

View File

@@ -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)

View File

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

View File

@@ -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"))

View File

@@ -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()

View File

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