Skip to content

Commit 69ff17b

Browse files
authored
add EncryptedDirectMessage class; simplify Event class (#39)
1 parent bda320f commit 69ff17b

File tree

5 files changed

+289
-38
lines changed

5 files changed

+289
-38
lines changed

README.md

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ public_key = private_key.public_key
1111
print(f"Private key: {private_key.bech32()}")
1212
print(f"Public key: {public_key.bech32()}")
1313
```
14+
1415
**Connect to relays**
1516
```python
1617
import json
@@ -30,6 +31,7 @@ while relay_manager.message_pool.has_notices():
3031

3132
relay_manager.close_connections()
3233
```
34+
3335
**Publish to relays**
3436
```python
3537
import json
@@ -48,14 +50,46 @@ time.sleep(1.25) # allow the connections to open
4850

4951
private_key = PrivateKey()
5052

51-
event = Event(private_key.public_key.hex(), "Hello Nostr")
53+
event = Event("Hello Nostr")
5254
private_key.sign_event(event)
5355

5456
relay_manager.publish_event(event)
5557
time.sleep(1) # allow the messages to send
5658

5759
relay_manager.close_connections()
5860
```
61+
62+
**Reply to a note**
63+
```python
64+
from nostr.event import Event
65+
66+
reply = Event(
67+
content="Hey, that's a great point!",
68+
)
69+
70+
# create 'e' tag reference to the note you're replying to
71+
reply.add_event_ref(original_note_id)
72+
73+
# create 'p' tag reference to the pubkey you're replying to
74+
reply.add_pubkey_ref(original_note_author_pubkey)
75+
76+
private_key.sign_event(reply)
77+
relay_manager.publish_event(reply)
78+
```
79+
80+
**Send a DM**
81+
```python
82+
from nostr.event import EncryptedDirectMessage
83+
84+
dm = EncryptedDirectMessage(
85+
recipient_pubkey=recipient_pubkey,
86+
cleartext_content="Secret message!"
87+
)
88+
private_key.sign_event(dm)
89+
relay_manager.publish_event(dm)
90+
```
91+
92+
5993
**Receive events from relays**
6094
```python
6195
import json
@@ -112,7 +146,6 @@ delegation = Delegation(
112146
identity_pk.sign_delegation(delegation)
113147

114148
event = Event(
115-
delegatee_pk.public_key.hex(),
116149
"Hello, NIP-26!",
117150
tags=[delegation.get_tag()],
118151
)

nostr/event.py

Lines changed: 77 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import time
22
import json
3+
from dataclasses import dataclass, field
34
from enum import IntEnum
5+
from typing import List
46
from secp256k1 import PrivateKey, PublicKey
57
from hashlib import sha256
68

79
from nostr.message_type import ClientMessageType
810

911

12+
1013
class EventKind(IntEnum):
1114
SET_METADATA = 0
1215
TEXT_NOTE = 1
@@ -16,41 +19,58 @@ class EventKind(IntEnum):
1619
DELETE = 5
1720

1821

19-
class Event():
20-
def __init__(
21-
self,
22-
public_key: str,
23-
content: str,
24-
created_at: int = None,
25-
kind: int=EventKind.TEXT_NOTE,
26-
tags: "list[list[str]]"=[],
27-
id: str=None,
28-
signature: str=None) -> None:
29-
if not isinstance(content, str):
22+
23+
@dataclass
24+
class Event:
25+
content: str = None
26+
public_key: str = None
27+
created_at: int = None
28+
kind: int = EventKind.TEXT_NOTE
29+
tags: List[List[str]] = field(default_factory=list) # Dataclasses require special handling when the default value is a mutable type
30+
signature: str = None
31+
32+
33+
def __post_init__(self):
34+
if self.content is not None and not isinstance(self.content, str):
35+
# DMs initialize content to None but all other kinds should pass in a str
3036
raise TypeError("Argument 'content' must be of type str")
3137

32-
self.public_key = public_key
33-
self.content = content
34-
self.created_at = created_at or int(time.time())
35-
self.kind = kind
36-
self.tags = tags
37-
self.signature = signature
38-
self.id = id or Event.compute_id(self.public_key, self.created_at, self.kind, self.tags, self.content)
38+
if self.created_at is None:
39+
self.created_at = int(time.time())
40+
3941

4042
@staticmethod
41-
def serialize(public_key: str, created_at: int, kind: int, tags: "list[list[str]]", content: str) -> bytes:
43+
def serialize(public_key: str, created_at: int, kind: int, tags: List[List[str]], content: str) -> bytes:
4244
data = [0, public_key, created_at, kind, tags, content]
4345
data_str = json.dumps(data, separators=(',', ':'), ensure_ascii=False)
4446
return data_str.encode()
4547

48+
4649
@staticmethod
47-
def compute_id(public_key: str, created_at: int, kind: int, tags: "list[list[str]]", content: str) -> str:
50+
def compute_id(public_key: str, created_at: int, kind: int, tags: List[List[str]], content: str):
4851
return sha256(Event.serialize(public_key, created_at, kind, tags, content)).hexdigest()
4952

53+
54+
@property
55+
def id(self) -> str:
56+
# Always recompute the id to reflect the up-to-date state of the Event
57+
return Event.compute_id(self.public_key, self.created_at, self.kind, self.tags, self.content)
58+
59+
60+
def add_pubkey_ref(self, pubkey:str):
61+
""" Adds a reference to a pubkey as a 'p' tag """
62+
self.tags.append(['p', pubkey])
63+
64+
65+
def add_event_ref(self, event_id:str):
66+
""" Adds a reference to an event_id as an 'e' tag """
67+
self.tags.append(['e', event_id])
68+
69+
5070
def verify(self) -> bool:
51-
pub_key = PublicKey(bytes.fromhex("02" + self.public_key), True) # add 02 for schnorr (bip340)
52-
event_id = Event.compute_id(self.public_key, self.created_at, self.kind, self.tags, self.content)
53-
return pub_key.schnorr_verify(bytes.fromhex(event_id), bytes.fromhex(self.signature), None, raw=True)
71+
pub_key = PublicKey(bytes.fromhex("02" + self.public_key), True) # add 02 for schnorr (bip340)
72+
return pub_key.schnorr_verify(bytes.fromhex(self.id), bytes.fromhex(self.signature), None, raw=True)
73+
5474

5575
def to_message(self) -> str:
5676
return json.dumps(
@@ -67,3 +87,37 @@ def to_message(self) -> str:
6787
}
6888
]
6989
)
90+
91+
92+
93+
@dataclass
94+
class EncryptedDirectMessage(Event):
95+
recipient_pubkey: str = None
96+
cleartext_content: str = None
97+
reference_event_id: str = None
98+
99+
100+
def __post_init__(self):
101+
if self.content is not None:
102+
self.cleartext_content = self.content
103+
self.content = None
104+
105+
if self.recipient_pubkey is None:
106+
raise Exception("Must specify a recipient_pubkey.")
107+
108+
self.kind = EventKind.ENCRYPTED_DIRECT_MESSAGE
109+
super().__post_init__()
110+
111+
# Must specify the DM recipient's pubkey in a 'p' tag
112+
self.add_pubkey_ref(self.recipient_pubkey)
113+
114+
# Optionally specify a reference event (DM) this is a reply to
115+
if self.reference_event_id is not None:
116+
self.add_event_ref(self.reference_event_id)
117+
118+
119+
@property
120+
def id(self) -> str:
121+
if self.content is None:
122+
raise Exception("EncryptedDirectMessage `id` is undefined until its message is encrypted and stored in the `content` field")
123+
return super().id

nostr/key.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from hashlib import sha256
88

99
from nostr.delegation import Delegation
10-
from nostr.event import Event
10+
from nostr.event import EncryptedDirectMessage, Event, EventKind
1111
from . import bech32
1212

1313

@@ -77,6 +77,9 @@ def encrypt_message(self, message: str, public_key_hex: str) -> str:
7777
encrypted_message = encryptor.update(padded_data) + encryptor.finalize()
7878

7979
return f"{base64.b64encode(encrypted_message).decode()}?iv={base64.b64encode(iv).decode()}"
80+
81+
def encrypt_dm(self, dm: EncryptedDirectMessage) -> None:
82+
dm.content = self.encrypt_message(message=dm.cleartext_content, public_key_hex=dm.recipient_pubkey)
8083

8184
def decrypt_message(self, encoded_message: str, public_key_hex: str) -> str:
8285
encoded_data = encoded_message.split('?iv=')
@@ -100,6 +103,10 @@ def sign_message_hash(self, hash: bytes) -> str:
100103
return sig.hex()
101104

102105
def sign_event(self, event: Event) -> None:
106+
if event.kind == EventKind.ENCRYPTED_DIRECT_MESSAGE and event.content is None:
107+
self.encrypt_dm(event)
108+
if event.public_key is None:
109+
event.public_key = self.public_key.hex()
103110
event.signature = self.sign_message_hash(bytes.fromhex(event.id))
104111

105112
def sign_delegation(self, delegation: Delegation) -> None:
@@ -108,6 +115,7 @@ def sign_delegation(self, delegation: Delegation) -> None:
108115
def __eq__(self, other):
109116
return self.raw_secret == other.raw_secret
110117

118+
111119
def mine_vanity_key(prefix: str = None, suffix: str = None) -> PrivateKey:
112120
if prefix is None and suffix is None:
113121
raise ValueError("Expected at least one of 'prefix' or 'suffix' arguments")
@@ -122,6 +130,7 @@ def mine_vanity_key(prefix: str = None, suffix: str = None) -> PrivateKey:
122130

123131
return sk
124132

133+
125134
ffi = FFI()
126135
@ffi.callback("int (unsigned char *, const unsigned char *, const unsigned char *, void *)")
127136
def copy_x(output, x32, y32, data):

test/test_event.py

Lines changed: 91 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,93 @@
1-
from nostr.event import Event
2-
from nostr.key import PrivateKey
1+
import pytest
32
import time
3+
from nostr.event import Event, EncryptedDirectMessage
4+
from nostr.key import PrivateKey
5+
6+
7+
8+
class TestEvent:
9+
def test_event_default_time(self):
10+
"""
11+
ensure created_at default value reflects the time at Event object instantiation
12+
see: https://github.com/jeffthibault/python-nostr/issues/23
13+
"""
14+
event1 = Event(content='test event')
15+
time.sleep(1.5)
16+
event2 = Event(content='test event')
17+
assert event1.created_at < event2.created_at
18+
19+
20+
def test_content_only_instantiation(self):
21+
""" should be able to create an Event by only specifying content without kwarg """
22+
event = Event("Hello, world!")
23+
assert event.content is not None
24+
25+
26+
def test_event_id_recomputes(self):
27+
""" should recompute the Event.id to reflect the current Event attrs """
28+
event = Event(content="some event")
29+
30+
# id should be computed on the fly
31+
event_id = event.id
32+
33+
event.created_at += 10
34+
35+
# Recomputed id should now be different
36+
assert event.id != event_id
37+
38+
39+
def test_add_event_ref(self):
40+
""" should add an 'e' tag for each event_ref added """
41+
some_event_id = "some_event_id"
42+
event = Event(content="Adding an 'e' tag")
43+
event.add_event_ref(some_event_id)
44+
assert ['e', some_event_id] in event.tags
45+
46+
47+
def test_add_pubkey_ref(self):
48+
""" should add a 'p' tag for each pubkey_ref added """
49+
some_pubkey = "some_pubkey"
50+
event = Event(content="Adding a 'p' tag")
51+
event.add_pubkey_ref(some_pubkey)
52+
assert ['p', some_pubkey] in event.tags
53+
54+
55+
56+
class TestEncryptedDirectMessage:
57+
def setup_class(self):
58+
self.sender_pk = PrivateKey()
59+
self.sender_pubkey = self.sender_pk.public_key.hex()
60+
self.recipient_pk = PrivateKey()
61+
self.recipient_pubkey = self.recipient_pk.public_key.hex()
62+
63+
64+
def test_content_field_moved_to_cleartext_content(self):
65+
""" Should transfer `content` field data to `cleartext_content` """
66+
dm = EncryptedDirectMessage(content="My message!", recipient_pubkey=self.recipient_pubkey)
67+
assert dm.content is None
68+
assert dm.cleartext_content is not None
69+
70+
71+
def test_nokwarg_content_allowed(self):
72+
""" Should allow creating a new DM w/no `content` nor `cleartext_content` kwarg """
73+
dm = EncryptedDirectMessage("My message!", recipient_pubkey=self.recipient_pubkey)
74+
assert dm.cleartext_content is not None
75+
76+
77+
def test_recipient_p_tag(self):
78+
""" Should generate recipient 'p' tag """
79+
dm = EncryptedDirectMessage(cleartext_content="Secret message!", recipient_pubkey=self.recipient_pubkey)
80+
assert ['p', self.recipient_pubkey] in dm.tags
81+
82+
83+
def test_unencrypted_dm_has_undefined_id(self):
84+
""" Should raise Exception if `id` is requested before DM is encrypted """
85+
dm = EncryptedDirectMessage(cleartext_content="My message!", recipient_pubkey=self.recipient_pubkey)
86+
87+
with pytest.raises(Exception) as e:
88+
dm.id
89+
assert "undefined" in str(e)
490

5-
def test_event_default_time():
6-
"""
7-
ensure created_at default value reflects the time at Event object instantiation
8-
see: https://github.com/jeffthibault/python-nostr/issues/23
9-
"""
10-
public_key = PrivateKey().public_key.hex()
11-
event1 = Event(public_key=public_key, content='test event')
12-
time.sleep(1.5)
13-
event2 = Event(public_key=public_key, content='test event')
14-
assert event1.created_at < event2.created_at
91+
# But once we encrypt it, we can request its id
92+
self.sender_pk.encrypt_dm(dm)
93+
assert dm.id is not None

0 commit comments

Comments
 (0)