Merge pull request #610 from PR0M3TH3AN/beta

Beta
This commit is contained in:
thePR0M3TH3AN
2025-07-17 16:06:01 -04:00
committed by GitHub
15 changed files with 288 additions and 70 deletions

View File

@@ -365,14 +365,15 @@ def handle_post_to_nostr(
Handles the action of posting the encrypted password index to Nostr.
"""
try:
event_id = password_manager.sync_vault(alt_summary=alt_summary)
if event_id:
print(
colored(
f"\N{WHITE HEAVY CHECK MARK} Sync complete. Event ID: {event_id}",
"green",
)
)
result = password_manager.sync_vault(alt_summary=alt_summary)
if result:
print(colored("\N{WHITE HEAVY CHECK MARK} Sync complete.", "green"))
print("Event IDs:")
print(f" manifest: {result['manifest_id']}")
for cid in result["chunk_ids"]:
print(f" chunk: {cid}")
for did in result["delta_ids"]:
print(f" delta: {did}")
logging.info("Encrypted index posted to Nostr successfully.")
else:
print(colored("\N{CROSS MARK} Sync failed…", "red"))

View File

@@ -14,6 +14,7 @@ class ChunkMeta:
id: str
size: int
hash: str
event_id: Optional[str] = None
@dataclass

View File

@@ -78,6 +78,7 @@ def prepare_snapshot(
id=f"seedpass-chunk-{i:04d}",
size=len(chunk),
hash=hashlib.sha256(chunk).hexdigest(),
event_id=None,
)
)
@@ -372,7 +373,13 @@ class NostrClient:
[Tag.identifier(meta.id)]
)
event = builder.build(self.keys.public_key()).sign_with_keys(self.keys)
await self.client.send_event(event)
result = await self.client.send_event(event)
try:
meta.event_id = (
result.id.to_hex() if hasattr(result, "id") else str(result)
)
except Exception:
meta.event_id = None
manifest_json = json.dumps(
{
@@ -400,6 +407,60 @@ class NostrClient:
logger.info("publish_snapshot completed in %.2f seconds", duration)
return manifest, manifest_id
async def _fetch_chunks_with_retry(
self, manifest_event
) -> tuple[Manifest, list[bytes]] | None:
"""Retrieve all chunks referenced by ``manifest_event`` with retries."""
pubkey = self.keys.public_key()
timeout = timedelta(seconds=10)
try:
data = json.loads(manifest_event.content())
manifest = Manifest(
ver=data["ver"],
algo=data["algo"],
chunks=[ChunkMeta(**c) for c in data["chunks"]],
delta_since=(
int(data["delta_since"])
if data.get("delta_since") is not None
else None
),
)
except Exception:
return None
chunks: list[bytes] = []
for meta in manifest.chunks:
attempt = 0
chunk_bytes: bytes | None = None
while attempt < MAX_RETRIES:
cf = Filter().author(pubkey).kind(Kind(KIND_SNAPSHOT_CHUNK))
if meta.event_id:
cf = cf.id(EventId.parse(meta.event_id))
else:
cf = cf.identifier(meta.id)
cf = cf.limit(1)
cev = (await self.client.fetch_events(cf, timeout)).to_vec()
if cev:
candidate = base64.b64decode(cev[0].content().encode("utf-8"))
if hashlib.sha256(candidate).hexdigest() == meta.hash:
chunk_bytes = candidate
break
attempt += 1
if attempt < MAX_RETRIES:
await asyncio.sleep(RETRY_DELAY)
if chunk_bytes is None:
return None
chunks.append(chunk_bytes)
man_id = getattr(manifest_event, "id", None)
if hasattr(man_id, "to_hex"):
man_id = man_id.to_hex()
self.current_manifest = manifest
self.current_manifest_id = man_id
return manifest, chunks
async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None:
"""Retrieve the latest manifest and all snapshot chunks."""
if self.offline_mode or not self.relays:
@@ -407,48 +468,18 @@ class NostrClient:
await self._connect_async()
pubkey = self.keys.public_key()
f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).limit(1)
f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).limit(3)
timeout = timedelta(seconds=10)
events = (await self.client.fetch_events(f, timeout)).to_vec()
if not events:
return None
manifest_event = events[0]
manifest_raw = manifest_event.content()
data = json.loads(manifest_raw)
manifest = Manifest(
ver=data["ver"],
algo=data["algo"],
chunks=[ChunkMeta(**c) for c in data["chunks"]],
delta_since=(
int(data["delta_since"])
if data.get("delta_since") is not None
else None
),
)
chunks: list[bytes] = []
for meta in manifest.chunks:
cf = (
Filter()
.author(pubkey)
.kind(Kind(KIND_SNAPSHOT_CHUNK))
.identifier(meta.id)
.limit(1)
)
cev = (await self.client.fetch_events(cf, timeout)).to_vec()
if not cev:
raise ValueError(f"Missing chunk {meta.id}")
chunk_bytes = base64.b64decode(cev[0].content().encode("utf-8"))
if hashlib.sha256(chunk_bytes).hexdigest() != meta.hash:
raise ValueError(f"Checksum mismatch for chunk {meta.id}")
chunks.append(chunk_bytes)
for manifest_event in events:
result = await self._fetch_chunks_with_retry(manifest_event)
if result is not None:
return result
self.current_manifest = manifest
man_id = getattr(manifest_event, "id", None)
if hasattr(man_id, "to_hex"):
man_id = man_id.to_hex()
self.current_manifest_id = man_id
return manifest, chunks
return None
async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str:
"""Publish a delta event referencing a manifest."""

View File

@@ -1127,7 +1127,7 @@ class PasswordManager:
def _worker() -> None:
try:
if hasattr(self, "nostr_client") and hasattr(self, "vault"):
self.sync_index_from_nostr_if_missing()
self.attempt_initial_sync()
if hasattr(self, "sync_index_from_nostr"):
self.sync_index_from_nostr()
except Exception as exc:
@@ -1176,16 +1176,19 @@ class PasswordManager:
threading.Thread(target=_worker, daemon=True).start()
def sync_index_from_nostr_if_missing(self) -> None:
"""Retrieve the password database from Nostr if it doesn't exist locally.
def attempt_initial_sync(self) -> bool:
"""Attempt to download the initial vault snapshot from Nostr.
If no valid data is found or decryption fails, initialize a fresh local
database and publish it to Nostr.
Returns ``True`` if the snapshot was successfully downloaded and the
local index file was written. Returns ``False`` otherwise. The local
index file is not created on failure.
"""
index_file = self.fingerprint_dir / "seedpass_entries_db.json.enc"
if index_file.exists():
return
return True
have_data = False
start = time.perf_counter()
try:
result = asyncio.run(self.nostr_client.fetch_latest_snapshot())
if result:
@@ -1202,10 +1205,23 @@ class PasswordManager:
if success:
logger.info("Initialized local database from Nostr.")
have_data = True
except Exception as e:
except Exception as e: # pragma: no cover - network errors
logger.warning(f"Unable to sync index from Nostr: {e}")
finally:
if getattr(self, "verbose_timing", False):
duration = time.perf_counter() - start
logger.info("attempt_initial_sync completed in %.2f seconds", duration)
if not have_data:
return have_data
def sync_index_from_nostr_if_missing(self) -> None:
"""Retrieve the password database from Nostr if it doesn't exist locally.
If no valid data is found or decryption fails, initialize a fresh local
database and publish it to Nostr.
"""
success = self.attempt_initial_sync()
if not success:
self.vault.save_index({"schema_version": LATEST_VERSION, "entries": {}})
try:
self.sync_vault()
@@ -3501,8 +3517,10 @@ class PasswordManager:
# Re-raise the exception to inform the calling function of the failure
raise
def sync_vault(self, alt_summary: str | None = None) -> str | None:
"""Publish the current vault contents to Nostr."""
def sync_vault(
self, alt_summary: str | None = None
) -> dict[str, list[str] | str] | None:
"""Publish the current vault contents to Nostr and return event IDs."""
try:
if getattr(self, "offline_mode", False):
return None
@@ -3510,16 +3528,28 @@ class PasswordManager:
if not encrypted:
return None
pub_snap = getattr(self.nostr_client, "publish_snapshot", None)
manifest = None
event_id = None
if callable(pub_snap):
if asyncio.iscoroutinefunction(pub_snap):
_, event_id = asyncio.run(pub_snap(encrypted))
manifest, event_id = asyncio.run(pub_snap(encrypted))
else:
_, event_id = pub_snap(encrypted)
manifest, event_id = pub_snap(encrypted)
else:
# Fallback for tests using simplified stubs
event_id = self.nostr_client.publish_json_to_nostr(encrypted)
self.is_dirty = False
return event_id
if event_id is None:
return None
chunk_ids: list[str] = []
if manifest is not None:
chunk_ids = [c.event_id for c in manifest.chunks if c.event_id]
delta_ids = getattr(self.nostr_client, "_delta_events", [])
return {
"manifest_id": event_id,
"chunk_ids": chunk_ids,
"delta_ids": list(delta_ids),
}
except Exception as e:
logging.error(f"Failed to sync vault: {e}", exc_info=True)
return None

View File

@@ -419,9 +419,14 @@ def vault_reveal_parent_seed(
def nostr_sync(ctx: typer.Context) -> None:
"""Sync with configured Nostr relays."""
pm = _get_pm(ctx)
event_id = pm.sync_vault()
if event_id:
typer.echo(event_id)
result = pm.sync_vault()
if result:
typer.echo("Event IDs:")
typer.echo(f"- manifest: {result['manifest_id']}")
for cid in result["chunk_ids"]:
typer.echo(f"- chunk: {cid}")
for did in result["delta_ids"]:
typer.echo(f"- delta: {did}")
else:
typer.echo("Error: Failed to sync vault")

View File

@@ -108,6 +108,7 @@ class DummyFilter:
self.ids: list[str] = []
self.limit_val: int | None = None
self.since_val: int | None = None
self.id_called: bool = False
def author(self, _pk):
return self
@@ -125,6 +126,11 @@ class DummyFilter:
self.ids.append(ident)
return self
def id(self, ident: str):
self.id_called = True
self.ids.append(ident)
return self
def limit(self, val: int):
self.limit_val = val
return self
@@ -167,6 +173,7 @@ class DummyRelayClient:
self.manifests: list[DummyEvent] = []
self.chunks: dict[str, DummyEvent] = {}
self.deltas: list[DummyEvent] = []
self.filters: list[DummyFilter] = []
async def add_relays(self, _relays):
pass
@@ -195,6 +202,7 @@ class DummyRelayClient:
elif event.kind == KIND_SNAPSHOT_CHUNK:
ident = event.tags[0] if event.tags else str(self.counter)
self.chunks[ident] = event
self.chunks[eid] = event
elif event.kind == KIND_DELTA:
if not hasattr(event, "created_at"):
self.ts_counter += 1
@@ -203,6 +211,7 @@ class DummyRelayClient:
return DummySendResult(eid)
async def fetch_events(self, f, _timeout):
self.filters.append(f)
kind = getattr(f, "kind_val", None)
limit = getattr(f, "limit_val", None)
identifier = f.ids[0] if getattr(f, "ids", None) else None

View File

@@ -53,7 +53,11 @@ class DummyPM:
self.nostr_client = SimpleNamespace(
key_manager=SimpleNamespace(get_npub=lambda: "npub")
)
self.sync_vault = lambda: "event"
self.sync_vault = lambda: {
"manifest_id": "event",
"chunk_ids": ["c1"],
"delta_ids": [],
}
self.config_manager = SimpleNamespace(
load_config=lambda require_pin=False: {"inactivity_timeout": 30},
set_inactivity_timeout=lambda v: None,

View File

@@ -47,7 +47,8 @@ def test_full_sync_roundtrip(dummy_nostr_client):
manifest_id = relay.manifests[-1].id
# Manager B retrieves snapshot
pm_b.sync_index_from_nostr_if_missing()
result = pm_b.attempt_initial_sync()
assert result is True
entries = pm_b.entry_manager.list_entries()
assert [e[1] for e in entries] == ["site1"]

View File

@@ -47,7 +47,8 @@ def test_full_sync_roundtrip(dummy_nostr_client):
manifest_id = relay.manifests[-1].id
# Manager B retrieves snapshot
pm_b.sync_index_from_nostr_if_missing()
result = pm_b.attempt_initial_sync()
assert result is True
entries = pm_b.entry_manager.list_entries()
assert [e[1] for e in entries] == ["site1"]

View File

@@ -39,7 +39,7 @@ class MockClient:
class FakeId:
def to_hex(self_inner):
return "abcd"
return "a" * 64
class FakeOutput:
def __init__(self):

View File

@@ -7,6 +7,7 @@ from password_manager.entry_management import EntryManager
from password_manager.backup import BackupManager
from password_manager.config_manager import ConfigManager
from nostr.client import prepare_snapshot
from nostr.backup_models import KIND_SNAPSHOT_CHUNK
def test_manifest_generation(tmp_path):
@@ -35,10 +36,18 @@ def test_retrieve_multi_chunk_snapshot(dummy_nostr_client):
data = os.urandom(120000)
manifest, _ = asyncio.run(client.publish_snapshot(data, limit=50000))
assert len(manifest.chunks) > 1
for meta in manifest.chunks:
assert meta.event_id
fetched_manifest, chunk_bytes = asyncio.run(client.fetch_latest_snapshot())
assert len(chunk_bytes) == len(manifest.chunks)
assert [c.event_id for c in fetched_manifest.chunks] == [
c.event_id for c in manifest.chunks
]
joined = b"".join(chunk_bytes)
assert gzip.decompress(joined) == data
for f in relay.filters:
if getattr(f, "kind_val", None) == KIND_SNAPSHOT_CHUNK:
assert f.id_called
def test_publish_and_fetch_deltas(dummy_nostr_client):
@@ -56,3 +65,70 @@ def test_publish_and_fetch_deltas(dummy_nostr_client):
assert relay.manifests[-1].delta_since == second_ts
deltas = asyncio.run(client.fetch_deltas_since(0))
assert deltas == [d1, d2]
def test_fetch_snapshot_fallback_on_missing_chunk(dummy_nostr_client, monkeypatch):
import os
import gzip
client, relay = dummy_nostr_client
monkeypatch.setattr("nostr.client.MAX_RETRIES", 3)
monkeypatch.setattr("nostr.client.RETRY_DELAY", 0)
data1 = os.urandom(60000)
manifest1, _ = asyncio.run(client.publish_snapshot(data1))
data2 = os.urandom(60000)
manifest2, _ = asyncio.run(client.publish_snapshot(data2))
missing = manifest2.chunks[0]
if missing.event_id:
relay.chunks.pop(missing.event_id, None)
relay.chunks.pop(missing.id, None)
relay.filters.clear()
fetched_manifest, chunk_bytes = asyncio.run(client.fetch_latest_snapshot())
assert gzip.decompress(b"".join(chunk_bytes)) == data1
assert [c.event_id for c in fetched_manifest.chunks] == [
c.event_id for c in manifest1.chunks
]
attempts = sum(
1
for f in relay.filters
if getattr(f, "kind_val", None) == KIND_SNAPSHOT_CHUNK
and (
missing.id in getattr(f, "ids", [])
or (missing.event_id and missing.event_id in getattr(f, "ids", []))
)
)
assert attempts == 3
def test_fetch_snapshot_uses_event_ids(dummy_nostr_client):
import os
import gzip
client, relay = dummy_nostr_client
data = os.urandom(60000)
manifest, _ = asyncio.run(client.publish_snapshot(data))
# Remove identifier keys so chunks can only be fetched via event_id
for meta in manifest.chunks:
relay.chunks.pop(meta.id, None)
relay.filters.clear()
fetched_manifest, chunk_bytes = asyncio.run(client.fetch_latest_snapshot())
assert gzip.decompress(b"".join(chunk_bytes)) == data
id_filters = [
f.id_called
for f in relay.filters
if getattr(f, "kind_val", None) == KIND_SNAPSHOT_CHUNK
]
assert id_filters and all(id_filters)

View File

@@ -68,6 +68,8 @@ class DummyClient:
def test_fetch_latest_snapshot():
data = b"seedpass" * 1000
manifest, chunks = prepare_snapshot(data, 50000)
for i, m in enumerate(manifest.chunks):
m.event_id = f"{i:064x}"
manifest_json = json.dumps(
{
"ver": manifest.ver,
@@ -98,3 +100,6 @@ def test_fetch_latest_snapshot():
assert manifest == result_manifest
assert result_chunks == chunks
assert [c.event_id for c in manifest.chunks] == [
c.event_id for c in result_manifest.chunks
]

View File

@@ -9,12 +9,17 @@ import main
def test_handle_post_success(capsys):
pm = SimpleNamespace(
sync_vault=lambda alt_summary=None: "abcd",
sync_vault=lambda alt_summary=None: {
"manifest_id": "abcd",
"chunk_ids": ["c1", "c2"],
"delta_ids": ["d1"],
},
)
main.handle_post_to_nostr(pm)
out = capsys.readouterr().out
assert "✅ Sync complete." in out
assert "abcd" in out
assert "c1" in out and "c2" in out and "d1" in out
def test_handle_post_failure(capsys):
@@ -24,3 +29,24 @@ def test_handle_post_failure(capsys):
main.handle_post_to_nostr(pm)
out = capsys.readouterr().out
assert "❌ Sync failed…" in out
def test_handle_post_prints_all_ids(capsys):
pm = SimpleNamespace(
sync_vault=lambda alt_summary=None: {
"manifest_id": "m1",
"chunk_ids": ["c1", "c2"],
"delta_ids": ["d1", "d2"],
}
)
main.handle_post_to_nostr(pm)
out_lines = capsys.readouterr().out.splitlines()
expected = [
" manifest: m1",
" chunk: c1",
" chunk: c2",
" delta: d1",
" delta: d2",
]
for line in expected:
assert any(line in ol for ol in out_lines)

View File

@@ -81,6 +81,28 @@ def test_sync_index_missing_bad_data(monkeypatch, dummy_nostr_client):
)
monkeypatch.setattr(client, "fetch_deltas_since", lambda *_a, **_k: [])
pm.sync_index_from_nostr_if_missing()
data = pm.vault.load_index()
assert data["entries"] == {}
result = pm.attempt_initial_sync()
assert result is False
index_path = dir_path / "seedpass_entries_db.json.enc"
assert not index_path.exists()
def test_attempt_initial_sync_incomplete_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
# Simulate relay snapshot retrieval failure due to missing chunks
monkeypatch.setattr(client, "fetch_latest_snapshot", lambda: None)
result = pm.attempt_initial_sync()
assert result is False
index_path = dir_path / "seedpass_entries_db.json.enc"
assert not index_path.exists()

View File

@@ -288,7 +288,11 @@ def test_nostr_sync(monkeypatch):
def sync_vault():
called["called"] = True
return "evt123"
return {
"manifest_id": "evt123",
"chunk_ids": ["c1"],
"delta_ids": ["d1"],
}
pm = SimpleNamespace(sync_vault=sync_vault, select_fingerprint=lambda fp: None)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
@@ -296,6 +300,8 @@ def test_nostr_sync(monkeypatch):
assert result.exit_code == 0
assert called.get("called") is True
assert "evt123" in result.stdout
assert "c1" in result.stdout
assert "d1" in result.stdout
def test_generate_password(monkeypatch):