diff --git a/README.md b/README.md index e17fc72..d209e77 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - **Deterministic Password Generation:** Utilize BIP-85 for generating deterministic and secure passwords. - **Encrypted Storage:** All seeds, login passwords, and sensitive index data are encrypted locally. - **Nostr Integration:** Post and retrieve your encrypted password index to/from the Nostr network. -- **Chunked Snapshots:** Encrypted vaults are compressed and split into 50 KB chunks published as `kind 30071` events with a `kind 30070` manifest and `kind 30072` deltas. +- **Chunked Snapshots:** Encrypted vaults are compressed and split into 50 KB chunks published as `kind 30071` events with a `kind 30070` manifest and `kind 30072` deltas. The manifest's `delta_since` field records the UNIX timestamp of the most recent delta. - **Automatic Checksum Generation:** The script generates and verifies a SHA-256 checksum to detect tampering. - **Multiple Seed Profiles:** Manage separate seed profiles and switch between them seamlessly. - **Nested Managed Account Seeds:** SeedPass can derive nested managed account seeds. diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index ffc6439..1b5f6d5 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -40,7 +40,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - **Deterministic Password Generation:** Utilize BIP-85 for generating deterministic and secure passwords. - **Encrypted Storage:** All seeds, login passwords, and sensitive index data are encrypted locally. - **Nostr Integration:** Post and retrieve your encrypted password index to/from the Nostr network. -- **Chunked Snapshots:** Encrypted vaults are compressed and split into 50 KB chunks published as `kind 30071` events with a `kind 30070` manifest and `kind 30072` deltas. +- **Chunked Snapshots:** Encrypted vaults are compressed and split into 50 KB chunks published as `kind 30071` events with a `kind 30070` manifest and `kind 30072` deltas. The manifest's `delta_since` field stores the UNIX timestamp of the most recent delta. - **Automatic Checksum Generation:** The script generates and verifies a SHA-256 checksum to detect tampering. - **Multiple Seed Profiles:** Manage separate seed profiles and switch between them seamlessly. - **Nested Managed Account Seeds:** SeedPass can derive nested managed account seeds. diff --git a/src/main.py b/src/main.py index 733b12e..dbd043f 100644 --- a/src/main.py +++ b/src/main.py @@ -318,15 +318,12 @@ def handle_retrieve_from_nostr(password_manager: PasswordManager): 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 + version = int(manifest.delta_since) + deltas = asyncio.run( + password_manager.nostr_client.fetch_deltas_since(version) + ) + if deltas: + encrypted = deltas[-1] password_manager.encryption_manager.decrypt_and_save_index_from_nostr( encrypted ) diff --git a/src/nostr/backup_models.py b/src/nostr/backup_models.py index 2de676c..98210b9 100644 --- a/src/nostr/backup_models.py +++ b/src/nostr/backup_models.py @@ -23,4 +23,4 @@ class Manifest: ver: int algo: str chunks: List[ChunkMeta] - delta_since: Optional[str] = None + delta_since: Optional[int] = None diff --git a/src/nostr/client.py b/src/nostr/client.py index 1a6bcb8..fa24873 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -135,6 +135,7 @@ class NostrClient: self.delta_threshold = 100 self.current_manifest: Manifest | None = None + self.current_manifest_id: str | None = None self._delta_events: list[str] = [] # Configure and initialize the nostr-sdk Client @@ -388,6 +389,8 @@ class NostrClient: result = await self.client.send_event(manifest_event) manifest_id = result.id.to_hex() if hasattr(result, "id") else str(result) self.current_manifest = manifest + self.current_manifest_id = manifest_id + self.current_manifest.delta_since = int(time.time()) self._delta_events = [] if getattr(self, "verbose_timing", False): duration = time.perf_counter() - start @@ -406,13 +409,18 @@ class NostrClient: events = (await self.client.fetch_events(f, timeout)).to_vec() if not events: return None - manifest_raw = events[0].content() + 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=data.get("delta_since"), + delta_since=( + int(data["delta_since"]) + if data.get("delta_since") is not None + else None + ), ) chunks: list[bytes] = [] @@ -433,6 +441,7 @@ class NostrClient: chunks.append(chunk_bytes) self.current_manifest = manifest + self.current_manifest_id = getattr(manifest_event, "id", None) return manifest, chunks async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str: @@ -447,8 +456,28 @@ class NostrClient: event = builder.build(self.keys.public_key()).sign_with_keys(self.keys) result = await self.client.send_event(event) delta_id = result.id.to_hex() if hasattr(result, "id") else str(result) + created_at = getattr( + event, "created_at", getattr(event, "timestamp", int(time.time())) + ) + if hasattr(created_at, "secs"): + created_at = created_at.secs if self.current_manifest is not None: - self.current_manifest.delta_since = delta_id + self.current_manifest.delta_since = int(created_at) + manifest_json = json.dumps( + { + "ver": self.current_manifest.ver, + "algo": self.current_manifest.algo, + "chunks": [meta.__dict__ for meta in self.current_manifest.chunks], + "delta_since": self.current_manifest.delta_since, + } + ) + manifest_event = ( + EventBuilder(Kind(KIND_MANIFEST), manifest_json) + .tags([Tag.identifier(self.current_manifest_id)]) + .build(self.keys.public_key()) + .sign_with_keys(self.keys) + ) + await self.client.send_event(manifest_event) self._delta_events.append(delta_id) return delta_id diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index b37bb8c..c003d5d 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1022,13 +1022,10 @@ class PasswordManager: 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 + version = int(manifest.delta_since) + deltas = asyncio.run(self.nostr_client.fetch_deltas_since(version)) + if deltas: + encrypted = deltas[-1] current = self.vault.get_encrypted_index() if current != encrypted: self.vault.decrypt_and_save_index_from_nostr(encrypted) @@ -1108,15 +1105,10 @@ class PasswordManager: 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 + version = int(manifest.delta_since) + deltas = asyncio.run(self.nostr_client.fetch_deltas_since(version)) + if deltas: + encrypted = deltas[-1] try: self.vault.decrypt_and_save_index_from_nostr(encrypted) logger.info("Initialized local database from Nostr.") @@ -3841,4 +3833,6 @@ class PasswordManager: print(color_text(f"Snapshot chunks: {stats['chunk_count']}", "stats")) print(color_text(f"Pending deltas: {stats['pending_deltas']}", "stats")) if stats.get("delta_since"): - print(color_text(f"Latest delta id: {stats['delta_since']}", "stats")) + print( + color_text(f"Latest delta timestamp: {stats['delta_since']}", "stats") + )