mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-09 15:58:48 +00:00
Merge pull request #149 from PR0M3TH3AN/codex/refactor-sync-and-vault-update-logic
Implement vault sync with Nostr snapshots
This commit is contained in:
44
src/main.py
44
src/main.py
@@ -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.")
|
||||||
|
@@ -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}"
|
||||||
|
@@ -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)
|
||||||
|
|
||||||
|
@@ -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
|
||||||
|
@@ -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"))
|
||||||
|
@@ -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()
|
||||||
|
@@ -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
|
||||||
|
Reference in New Issue
Block a user