Compare commits
7 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4811fcf352 | |||
| b74af2628e | |||
| 8d6f482de0 | |||
|
|
5e95b309fe | ||
|
|
8547864254 | ||
|
|
dcc3204735 | ||
|
|
8bfd792548 |
5 changed files with 243 additions and 18 deletions
12
README.md
12
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
# 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 = [e.id for e in events_to_delete if not e.is_delete_event]
|
||||
await mark_events_deleted(self.relay_id, NostrFilter(ids=ids))
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
}
|
||||
|
|
|
|||
132
tests/test_nip17.py
Normal file
132
tests/test_nip17.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue