Compare commits

..

No commits in common. "main" and "v1.1.0" have entirely different histories.

5 changed files with 18 additions and 243 deletions

View file

@ -24,8 +24,6 @@
- [x] **NIP-04**: Encrypted Direct Message - [x] **NIP-04**: Encrypted Direct Message
- if `AUTH` enabled: send only to the intended target - if `AUTH` enabled: send only to the intended target
- [x] **NIP-09**: Event Deletion - [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 - [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/) - > **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 - [ ] **NIP-12**: Generic Tag Queries
@ -35,12 +33,6 @@
- [x] Regular Events - [x] Regular Events
- [x] Replaceable Events - [x] Replaceable Events
- [x] Ephemeral 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 - [x] **NIP-20**: Command Results
- todo: use correct prefixes - todo: use correct prefixes
- [x] **NIP-22**: Event created_at Limits - [x] **NIP-22**: Event created_at Limits
@ -54,12 +46,8 @@
- todo - todo
- [x] **NIP-42**: Authentication of clients to relays - [x] **NIP-42**: Authentication of clients to relays
- todo: use correct prefix - todo: use correct prefix
- [x] **NIP-44**: Encrypted Payloads (Versioned)
- relay treats payloads as opaque; encryption is client-side
- [ ] **NIP-50**: Search Capability - [ ] **NIP-50**: Search Capability
- todo - todo
- [x] **NIP-59**: Gift Wrap
- `kind: 13` (seal) and `kind: 1059` (gift wrap) accepted; unwrapping is client-side
## Create Relay ## Create Relay

View file

@ -69,7 +69,7 @@ class NostrClientConnection:
self.event_validator.get_client_config = get_client_config self.event_validator.get_client_config = get_client_config
async def notify_event(self, event: NostrEvent) -> bool: async def notify_event(self, event: NostrEvent) -> bool:
if self._is_private_event_for_other(event): if self._is_direct_message_for_other(event):
return False return False
for nostr_filter in self.filters: for nostr_filter in self.filters:
@ -77,20 +77,15 @@ class NostrClientConnection:
resp = event.serialize_response(nostr_filter.subscription_id) resp = event.serialize_response(nostr_filter.subscription_id)
await self._send_msg(resp) await self._send_msg(resp)
return True return True
else:
logger.info(
f"[NOSTRRELAY CLIENT] ❌ Filter didn't match for event {event.id}"
)
return False return False
def _is_private_event_for_other(self, event: NostrEvent) -> bool: def _is_direct_message_for_other(self, event: NostrEvent) -> bool:
""" """
p-tagged events that carry a single intended recipient (NIP-04 kind 4 Direct messages are not inteded to be boradcast (even if encrypted).
direct messages and NIP-17 kind 1059 gift wraps) should not be If the server requires AUTH for kind '4' then direct message will be
broadcast to arbitrary subscribers when the relay enforces AUTH for sent only to the intended client.
that kind. Deliver only to the AUTH'd recipient named in a `p` tag.
""" """
if not event.is_private_message: if not event.is_direct_message:
return False return False
if not self.config.event_requires_auth(event.kind): if not self.config.event_requires_auth(event.kind):
return False return False
@ -103,10 +98,6 @@ class NostrClientConnection:
async def _broadcast_event(self, e: NostrEvent): async def _broadcast_event(self, e: NostrEvent):
if self.broadcast_event: if self.broadcast_event:
await self.broadcast_event(self, e) 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: async def _handle_message(self, data: list) -> list:
if len(data) < 2: if len(data) < 2:
@ -122,8 +113,6 @@ class NostrClientConnection:
} }
event = NostrEvent(**event_dict) event = NostrEvent(**event_dict)
# Set the size field from the size_bytes property
event.size = event.size_bytes
await self._handle_event(event) await self._handle_event(event)
return [] return []
if message_type == NostrEventType.REQ: if message_type == NostrEventType.REQ:
@ -131,8 +120,6 @@ class NostrClientConnection:
return [] return []
subscription_id = data[1] subscription_id = data[1]
# Handle multiple filters in REQ message # Handle multiple filters in REQ message
# First remove existing filters for this subscription_id
self._remove_filter(subscription_id)
responses = [] responses = []
for filter_data in data[2:]: for filter_data in data[2:]:
response = await self._handle_request( response = await self._handle_request(
@ -221,65 +208,12 @@ class NostrClientConnection:
await self.websocket.send_text(json.dumps(data)) await self.websocket.send_text(json.dumps(data))
async def _handle_delete_event(self, event: NostrEvent): async def _handle_delete_event(self, event: NostrEvent):
# NIP 09 - Handle both regular events (e tags) and parameterized replaceable events (a tags) # NIP 09
nostr_filter = NostrFilter(authors=[event.pubkey])
# Get event IDs from 'e' tags (for regular events) nostr_filter.ids = [t[1] for t in event.tags if t[0] == "e"]
event_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]
# Get event addresses from 'a' tags (for parameterized replaceable events) await mark_events_deleted(self.relay_id, NostrFilter(ids=ids))
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( async def _handle_request(
self, subscription_id: str, nostr_filter: NostrFilter self, subscription_id: str, nostr_filter: NostrFilter
@ -306,7 +240,8 @@ class NostrClientConnection:
return [["NOTICE", f"This is a paid relay: '{self.relay_id}'"]] return [["NOTICE", f"This is a paid relay: '{self.relay_id}'"]]
nostr_filter.subscription_id = subscription_id nostr_filter.subscription_id = subscription_id
if not self._can_add_filter(): self._remove_filter(subscription_id)
if self._can_add_filter():
max_filters = self.config.max_client_filters max_filters = self.config.max_client_filters
return [ return [
[ [
@ -318,7 +253,7 @@ class NostrClientConnection:
nostr_filter.enforce_limit(self.config.limit_per_filter) nostr_filter.enforce_limit(self.config.limit_per_filter)
self.filters.append(nostr_filter) self.filters.append(nostr_filter)
events = await get_events(self.relay_id, nostr_filter) events = await get_events(self.relay_id, nostr_filter)
events = [e for e in events if not self._is_private_event_for_other(e)] events = [e for e in events if not self._is_direct_message_for_other(e)]
serialized_events = [ serialized_events = [
event.serialize_response(subscription_id) for event in events event.serialize_response(subscription_id) for event in events
] ]
@ -337,8 +272,8 @@ class NostrClientConnection:
def _can_add_filter(self) -> bool: def _can_add_filter(self) -> bool:
return ( return (
self.config.max_client_filters == 0 self.config.max_client_filters != 0
or len(self.filters) < self.config.max_client_filters and len(self.filters) >= self.config.max_client_filters
) )
def _auth_challenge_expired(self): def _auth_challenge_expired(self):

View file

@ -23,7 +23,6 @@ class NostrEvent(BaseModel):
tags: list[list[str]] = Field(default=[], no_database=True) tags: list[list[str]] = Field(default=[], no_database=True)
content: str = "" content: str = ""
sig: str sig: str
size: int = 0
def nostr_dict(self) -> dict: def nostr_dict(self) -> dict:
_nostr_dict = dict(self) _nostr_dict = dict(self)
@ -64,21 +63,6 @@ class NostrEvent(BaseModel):
def is_delete_event(self) -> bool: def is_delete_event(self) -> bool:
return self.kind == 5 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 @property
def is_regular_event(self) -> bool: def is_regular_event(self) -> bool:
return self.kind >= 1000 and self.kind < 10000 return self.kind >= 1000 and self.kind < 10000

View file

@ -116,7 +116,7 @@ class NostrRelay(BaseModel):
) -> dict: ) -> dict:
return { return {
"contact": "https://t.me/lnbits", "contact": "https://t.me/lnbits",
"supported_nips": [1, 2, 4, 9, 11, 15, 16, 17, 20, 22, 28, 42, 44, 59], "supported_nips": [1, 2, 4, 9, 11, 15, 16, 20, 22, 28, 42],
"software": "LNbits", "software": "LNbits",
"version": "", "version": "",
} }

View file

@ -1,132 +0,0 @@
"""
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