diff --git a/README.md b/README.md index ecc18f8..d5f7ade 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ - [x] **NIP-04**: Encrypted Direct Message - if `AUTH` enabled: send only to the intended target - [x] **NIP-09**: Event Deletion + - [x] 'e' tags: Delete regular events by event ID + - [x] 'a' tags: Delete addressable events by address (kind:pubkey:d-identifier) - [x] **NIP-11**: Relay Information Document - > **Note**: the endpoint is NOT on the root level of the domain. It also includes a path (eg https://lnbits.link/nostrrelay/) - [ ] **NIP-12**: Generic Tag Queries @@ -33,6 +35,12 @@ - [x] Regular Events - [x] Replaceable Events - [x] Ephemeral Events +- [x] **NIP-17**: Private Direct Messages + - `kind: 1059` gift wraps stored and broadcast like regular events + - if `AUTH` enabled for `kind: 1059`: deliver only to the recipient + named in the `p` tag (same gating as NIP-04) + - encryption (NIP-44) and wrapping (NIP-59) are client-side concerns; + the relay handles transport only - [x] **NIP-20**: Command Results - todo: use correct prefixes - [x] **NIP-22**: Event created_at Limits @@ -46,8 +54,12 @@ - todo - [x] **NIP-42**: Authentication of clients to relays - todo: use correct prefix +- [x] **NIP-44**: Encrypted Payloads (Versioned) + - relay treats payloads as opaque; encryption is client-side - [ ] **NIP-50**: Search Capability - todo +- [x] **NIP-59**: Gift Wrap + - `kind: 13` (seal) and `kind: 1059` (gift wrap) accepted; unwrapping is client-side ## Create Relay diff --git a/relay/client_connection.py b/relay/client_connection.py index 77a25b9..b9c9708 100644 --- a/relay/client_connection.py +++ b/relay/client_connection.py @@ -69,7 +69,7 @@ class NostrClientConnection: self.event_validator.get_client_config = get_client_config async def notify_event(self, event: NostrEvent) -> bool: - if self._is_direct_message_for_other(event): + if self._is_private_event_for_other(event): return False for nostr_filter in self.filters: @@ -77,15 +77,20 @@ class NostrClientConnection: resp = event.serialize_response(nostr_filter.subscription_id) await self._send_msg(resp) return True + else: + logger.info( + f"[NOSTRRELAY CLIENT] ❌ Filter didn't match for event {event.id}" + ) return False - def _is_direct_message_for_other(self, event: NostrEvent) -> bool: + def _is_private_event_for_other(self, event: NostrEvent) -> bool: """ - Direct messages are not inteded to be boradcast (even if encrypted). - If the server requires AUTH for kind '4' then direct message will be - sent only to the intended client. + p-tagged events that carry a single intended recipient (NIP-04 kind 4 + direct messages and NIP-17 kind 1059 gift wraps) should not be + broadcast to arbitrary subscribers when the relay enforces AUTH for + that kind. Deliver only to the AUTH'd recipient named in a `p` tag. """ - if not event.is_direct_message: + if not event.is_private_message: return False if not self.config.event_requires_auth(event.kind): return False @@ -98,6 +103,10 @@ class NostrClientConnection: async def _broadcast_event(self, e: NostrEvent): if self.broadcast_event: await self.broadcast_event(self, e) + else: + logger.warning( + f"[NOSTRRELAY CLIENT] ❌ No broadcast_event callback available for event {e.id}" + ) async def _handle_message(self, data: list) -> list: if len(data) < 2: @@ -113,6 +122,8 @@ class NostrClientConnection: } event = NostrEvent(**event_dict) + # Set the size field from the size_bytes property + event.size = event.size_bytes await self._handle_event(event) return [] if message_type == NostrEventType.REQ: @@ -120,6 +131,8 @@ class NostrClientConnection: return [] subscription_id = data[1] # Handle multiple filters in REQ message + # First remove existing filters for this subscription_id + self._remove_filter(subscription_id) responses = [] for filter_data in data[2:]: response = await self._handle_request( @@ -208,12 +221,65 @@ class NostrClientConnection: await self.websocket.send_text(json.dumps(data)) async def _handle_delete_event(self, event: NostrEvent): - # NIP 09 - nostr_filter = NostrFilter(authors=[event.pubkey]) - nostr_filter.ids = [t[1] for t in event.tags if t[0] == "e"] - events_to_delete = await get_events(self.relay_id, nostr_filter, False) - ids = [e.id for e in events_to_delete if not e.is_delete_event] - await mark_events_deleted(self.relay_id, NostrFilter(ids=ids)) + # NIP 09 - Handle both regular events (e tags) and parameterized replaceable events (a tags) + + # Get event IDs from 'e' tags (for regular events) + event_ids = [t[1] for t in event.tags if t[0] == "e"] + + # Get event addresses from 'a' tags (for parameterized replaceable events) + event_addresses = [t[1] for t in event.tags if t[0] == "a"] + + ids_to_delete = [] + + # Handle regular event deletions (e tags) + if event_ids: + nostr_filter = NostrFilter(authors=[event.pubkey], ids=event_ids) + events_to_delete = await get_events(self.relay_id, nostr_filter, False) + ids_to_delete.extend( + [e.id for e in events_to_delete if not e.is_delete_event] + ) + + # Handle parameterized replaceable event deletions (a tags) + if event_addresses: + for addr in event_addresses: + # Parse address format: kind:pubkey:d-tag + parts = addr.split(":") + if len(parts) == 3: + kind_str, addr_pubkey, d_tag = parts + try: + kind = int(kind_str) + # Only delete if the address pubkey matches the deletion event author + if addr_pubkey == event.pubkey: + # NOTE: Use "#d" alias, not "d" directly (Pydantic Field alias) + nostr_filter = NostrFilter( + authors=[addr_pubkey], + kinds=[kind], + **{"#d": [d_tag]}, # Use alias to set d field + ) + events_to_delete = await get_events( + self.relay_id, nostr_filter, False + ) + ids_to_delete.extend( + [ + e.id + for e in events_to_delete + if not e.is_delete_event + ] + ) + else: + logger.warning( + f"Deletion request pubkey mismatch: {addr_pubkey} != {event.pubkey}" + ) + except ValueError: + logger.warning(f"Invalid kind in address: {addr}") + else: + logger.warning( + f"Invalid address format (expected kind:pubkey:d-tag): {addr}" + ) + + # Only mark events as deleted if we found specific IDs + if ids_to_delete: + await mark_events_deleted(self.relay_id, NostrFilter(ids=ids_to_delete)) async def _handle_request( self, subscription_id: str, nostr_filter: NostrFilter @@ -240,8 +306,7 @@ class NostrClientConnection: return [["NOTICE", f"This is a paid relay: '{self.relay_id}'"]] nostr_filter.subscription_id = subscription_id - self._remove_filter(subscription_id) - if self._can_add_filter(): + if not self._can_add_filter(): max_filters = self.config.max_client_filters return [ [ @@ -253,7 +318,7 @@ class NostrClientConnection: nostr_filter.enforce_limit(self.config.limit_per_filter) self.filters.append(nostr_filter) events = await get_events(self.relay_id, nostr_filter) - events = [e for e in events if not self._is_direct_message_for_other(e)] + events = [e for e in events if not self._is_private_event_for_other(e)] serialized_events = [ event.serialize_response(subscription_id) for event in events ] @@ -272,8 +337,8 @@ class NostrClientConnection: def _can_add_filter(self) -> bool: return ( - self.config.max_client_filters != 0 - and len(self.filters) >= self.config.max_client_filters + self.config.max_client_filters == 0 + or len(self.filters) < self.config.max_client_filters ) def _auth_challenge_expired(self): diff --git a/relay/event.py b/relay/event.py index 7154ece..b6343e0 100644 --- a/relay/event.py +++ b/relay/event.py @@ -23,6 +23,7 @@ class NostrEvent(BaseModel): tags: list[list[str]] = Field(default=[], no_database=True) content: str = "" sig: str + size: int = 0 def nostr_dict(self) -> dict: _nostr_dict = dict(self) @@ -63,6 +64,21 @@ class NostrEvent(BaseModel): def is_delete_event(self) -> bool: return self.kind == 5 + @property + def is_seal(self) -> bool: + return self.kind == 13 + + @property + def is_gift_wrap(self) -> bool: + return self.kind == 1059 + + @property + def is_private_message(self) -> bool: + # Kinds whose payload addresses a single recipient via a `p` tag and is + # not meant to be broadcast to other subscribers when AUTH is enforced. + # NIP-04 (kind 4) and NIP-17 (kind 1059 gift wrap). + return self.is_direct_message or self.is_gift_wrap + @property def is_regular_event(self) -> bool: return self.kind >= 1000 and self.kind < 10000 diff --git a/relay/relay.py b/relay/relay.py index 80677c7..29e0121 100644 --- a/relay/relay.py +++ b/relay/relay.py @@ -116,7 +116,7 @@ class NostrRelay(BaseModel): ) -> dict: return { "contact": "https://t.me/lnbits", - "supported_nips": [1, 2, 4, 9, 11, 15, 16, 20, 22, 28, 42], + "supported_nips": [1, 2, 4, 9, 11, 15, 16, 17, 20, 22, 28, 42, 44, 59], "software": "LNbits", "version": "", } diff --git a/tests/test_nip17.py b/tests/test_nip17.py new file mode 100644 index 0000000..c53070b --- /dev/null +++ b/tests/test_nip17.py @@ -0,0 +1,132 @@ +""" +Unit tests for NIP-17 (Private Direct Messages) handling. + +Covers: + - kind 1059 (gift wrap) and kind 13 (seal) classification on NostrEvent + - the AUTH-gated private-recipient delivery rule in NostrClientConnection +""" + +from unittest.mock import MagicMock + +import pytest + +from ..relay.client_connection import NostrClientConnection +from ..relay.event import NostrEvent +from ..relay.relay import RelaySpec + +RELAY_ID = "relay_nip17" +RECIPIENT = "1111111111111111111111111111111111111111111111111111111111111111" +OTHER = "2222222222222222222222222222222222222222222222222222222222222222" +EPHEMERAL = "3333333333333333333333333333333333333333333333333333333333333333" +SIG = "0" * 128 + + +def _gift_wrap_for(recipient: str) -> NostrEvent: + """Build a kind 1059 event addressed to recipient. Skips signature validity.""" + return NostrEvent( + id="0" * 64, + relay_id=RELAY_ID, + publisher=EPHEMERAL, + pubkey=EPHEMERAL, + created_at=0, + kind=1059, + tags=[["p", recipient]], + content="ciphertext", + sig=SIG, + ) + + +def test_kind_classification_helpers(): + seal = NostrEvent( + id="0" * 64, + relay_id=RELAY_ID, + publisher=OTHER, + pubkey=OTHER, + created_at=0, + kind=13, + tags=[], + content="", + sig=SIG, + ) + wrap = _gift_wrap_for(RECIPIENT) + + assert seal.is_seal + assert not seal.is_gift_wrap + assert not seal.is_private_message # seals carry no recipient metadata + + assert wrap.is_gift_wrap + assert not wrap.is_seal + assert wrap.is_private_message + assert not wrap.is_ephemeral_event # nostrmarket relies on storage + + +def _make_connection(relay_spec: RelaySpec) -> NostrClientConnection: + conn = NostrClientConnection(relay_id=RELAY_ID, websocket=MagicMock()) + conn.get_client_config = lambda: relay_spec + return conn + + +@pytest.mark.parametrize( + "force_auth,auth_pubkey,event_recipient,expected_filtered", + [ + # AUTH not required for 1059 -> never filtered (matches NIP-04 default) + (False, None, RECIPIENT, False), + (False, RECIPIENT, RECIPIENT, False), + (False, OTHER, RECIPIENT, False), + # AUTH required for 1059 -> only the recipient gets it + (True, None, RECIPIENT, True), + (True, RECIPIENT, RECIPIENT, False), + (True, OTHER, RECIPIENT, True), + ], +) +def test_gift_wrap_auth_gated_delivery( + force_auth, auth_pubkey, event_recipient, expected_filtered +): + spec = RelaySpec(forcedAuthEvents=[1059] if force_auth else []) + conn = _make_connection(spec) + conn.auth_pubkey = auth_pubkey + + wrap = _gift_wrap_for(event_recipient) + assert conn._is_private_event_for_other(wrap) is expected_filtered + + +def test_kind_4_dm_still_gated_under_auth(): + """Regression: the NIP-04 gating behavior must remain identical.""" + spec = RelaySpec(forcedAuthEvents=[4]) + conn = _make_connection(spec) + conn.auth_pubkey = OTHER + + dm = NostrEvent( + id="0" * 64, + relay_id=RELAY_ID, + publisher=RECIPIENT, + pubkey=RECIPIENT, + created_at=0, + kind=4, + tags=[["p", RECIPIENT]], + content="ciphertext", + sig=SIG, + ) + assert conn._is_private_event_for_other(dm) is True + + conn.auth_pubkey = RECIPIENT + assert conn._is_private_event_for_other(dm) is False + + +def test_non_private_kinds_never_filtered(): + spec = RelaySpec(forcedAuthEvents=[1059, 4]) + conn = _make_connection(spec) + conn.auth_pubkey = OTHER + + note = NostrEvent( + id="0" * 64, + relay_id=RELAY_ID, + publisher=RECIPIENT, + pubkey=RECIPIENT, + created_at=0, + kind=1, + tags=[["p", RECIPIENT]], + content="hello", + sig=SIG, + ) + assert conn._is_private_event_for_other(note) is False