From 4811fcf352c16cda06550fb93f1271e5a0af0dab Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 3 May 2026 13:59:14 +0200 Subject: [PATCH] feat(nip17): support gift-wrapped private direct messages Generalize the AUTH-gated, recipient-only delivery rule from NIP-04 to also cover NIP-17 kind 1059 gift wraps. When the relay is configured to require AUTH for kind 1059, only the AUTH'd recipient named in the event's `p` tag receives it; otherwise gift wraps broadcast like any regular event. - relay/event.py: add `is_seal`, `is_gift_wrap`, `is_private_message` helpers (kinds 13, 1059) - relay/client_connection.py: rename `_is_direct_message_for_other` -> `_is_private_event_for_other`; key off `is_private_message` so the same gating applies to kinds 4 and 1059 - relay/relay.py: advertise NIPs 17, 44, 59 in NIP-11 supported_nips - README: document NIP-17/44/59 transport-level support - tests/test_nip17.py: unit tests for kind classification, AUTH-gated 1059 delivery (recipient vs non-recipient vs unauthenticated), and regression coverage for kind 4 gating NIP-44 (encryption) and NIP-59 (wrap/seal) are client-side concerns; the relay treats payloads as opaque ciphertext and stores kind 1059 like any regular event. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 10 +++ relay/client_connection.py | 15 +++-- relay/event.py | 15 +++++ relay/relay.py | 2 +- tests/test_nip17.py | 132 +++++++++++++++++++++++++++++++++++++ 5 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 tests/test_nip17.py diff --git a/README.md b/README.md index 21ac93f..d5f7ade 100644 --- a/README.md +++ b/README.md @@ -35,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 @@ -48,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 8b5fdfd..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: @@ -83,13 +83,14 @@ class NostrClientConnection: ) 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 @@ -317,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 ] diff --git a/relay/event.py b/relay/event.py index 8cee1b9..b6343e0 100644 --- a/relay/event.py +++ b/relay/event.py @@ -64,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