Compare commits
1 commit
v1.1.0-aio
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4811fcf352 |
5 changed files with 166 additions and 8 deletions
10
README.md
10
README.md
|
|
@ -35,6 +35,12 @@
|
||||||
- [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
|
||||||
|
|
@ -48,8 +54,12 @@
|
||||||
- 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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_direct_message_for_other(event):
|
if self._is_private_event_for_other(event):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
for nostr_filter in self.filters:
|
for nostr_filter in self.filters:
|
||||||
|
|
@ -83,13 +83,14 @@ class NostrClientConnection:
|
||||||
)
|
)
|
||||||
return False
|
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).
|
p-tagged events that carry a single intended recipient (NIP-04 kind 4
|
||||||
If the server requires AUTH for kind '4' then direct message will be
|
direct messages and NIP-17 kind 1059 gift wraps) should not be
|
||||||
sent only to the intended client.
|
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
|
return False
|
||||||
if not self.config.event_requires_auth(event.kind):
|
if not self.config.event_requires_auth(event.kind):
|
||||||
return False
|
return False
|
||||||
|
|
@ -317,7 +318,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_direct_message_for_other(e)]
|
events = [e for e in events if not self._is_private_event_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
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,21 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -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, 20, 22, 28, 42],
|
"supported_nips": [1, 2, 4, 9, 11, 15, 16, 17, 20, 22, 28, 42, 44, 59],
|
||||||
"software": "LNbits",
|
"software": "LNbits",
|
||||||
"version": "",
|
"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