Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion nostr/delegation.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import time
from typing import Optional
from dataclasses import dataclass


Expand All @@ -8,7 +9,7 @@ class Delegation:
delegatee_pubkey: str
event_kind: int
duration_secs: int = 30*24*60 # default to 30 days
signature: str = None # set in PrivateKey.sign_delegation
signature: Optional[str] = None # set in PrivateKey.sign_delegation

@property
def expires(self) -> int:
Expand Down
60 changes: 38 additions & 22 deletions nostr/event.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import time
from typing import Optional
import json
from dataclasses import dataclass, field
from enum import IntEnum
from typing import List
from secp256k1 import PublicKey
from hashlib import sha256

from . import bech32
from .message_type import ClientMessageType
from .secp import PublicKey



Expand All @@ -23,12 +24,12 @@ class EventKind(IntEnum):

@dataclass
class Event:
content: str = None
public_key: str = None
created_at: int = None
kind: int = EventKind.TEXT_NOTE
content: Optional[str] = None
public_key: Optional[str] = None
created_at: Optional[int] = None
kind: Optional[int] = EventKind.TEXT_NOTE
tags: List[List[str]] = field(default_factory=list) # Dataclasses require special handling when the default value is a mutable type
signature: str = None
signature: Optional[str] = None


def __post_init__(self):
Expand Down Expand Up @@ -78,30 +79,45 @@ def verify(self) -> bool:
pub_key = PublicKey(bytes.fromhex("02" + self.public_key), True) # add 02 for schnorr (bip340)
return pub_key.schnorr_verify(bytes.fromhex(self.id), bytes.fromhex(self.signature), None, raw=True)


def to_message(self) -> str:
return json.dumps(
[
def to_json(self) -> list:
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useful!

return [
ClientMessageType.EVENT,
{
"id": self.id,
"pubkey": self.public_key,
"created_at": self.created_at,
"kind": self.kind,
"tags": self.tags,
"content": self.content,
"sig": self.signature
}
self.to_dict()
]

def to_message(self) -> str:
return json.dumps(self.to_json())

@classmethod
def from_dict(cls, msg: dict) -> 'Event':
# "id" is ignore, as it will be computed from the contents
return Event(
content=msg['content'],
public_key=msg['pubkey'],
created_at=msg['created_at'],
kind=msg['kind'],
tags=msg['tags'],
signature=msg['sig'],
)

def to_dict(self) -> dict:
return {
"id": self.id,
"pubkey": self.public_key,
"created_at": self.created_at,
"kind": self.kind,
"tags": self.tags,
"content": self.content,
"sig": self.signature
}



@dataclass
class EncryptedDirectMessage(Event):
recipient_pubkey: str = None
cleartext_content: str = None
reference_event_id: str = None
recipient_pubkey: Optional[str] = None
cleartext_content: Optional[str] = None
reference_event_id: Optional[str] = None


def __post_init__(self):
Expand Down
25 changes: 13 additions & 12 deletions nostr/filter.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from collections import UserList
from typing import List
from typing import List, Optional

from .event import Event, EventKind

Expand All @@ -16,20 +16,21 @@ class Filter:
added. For example:
# arbitrary tag
filter.add_arbitrary_tag('t', [hashtags])

# promoted to explicit support
Filter(hashtag_refs=[hashtags])
"""
def __init__(
self,
event_ids: List[str] = None,
kinds: List[EventKind] = None,
authors: List[str] = None,
since: int = None,
until: int = None,
event_refs: List[str] = None, # the "#e" attr; list of event ids referenced in an "e" tag
pubkey_refs: List[str] = None, # The "#p" attr; list of pubkeys referenced in a "p" tag
limit: int = None) -> None:
self,
event_ids: Optional[List[str]] = None,
kinds: Optional[List[EventKind]] = None,
authors: Optional[List[str]] = None,
since: Optional[int] = None,
until: Optional[int] = None,
event_refs: Optional[List[str]] = None, # the "#e" attr; list of event ids referenced in an "e" tag
pubkey_refs: Optional[List[str]] = None, # The "#p" attr; list of pubkeys referenced in a "p" tag
limit: Optional[int] = None,
) -> None:
self.event_ids = event_ids
self.kinds = kinds
self.authors = authors
Expand Down Expand Up @@ -128,4 +129,4 @@ def match(self, event: Event):
return False

def to_json_array(self) -> list:
return [filter.to_json_object() for filter in self.data]
return [filter.to_json_object() for filter in self.data]
22 changes: 13 additions & 9 deletions nostr/key.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import secrets
import base64
import secp256k1
from cffi import FFI
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
from hashlib import sha256
from typing import cast, Optional

import nostr.secp as secp256k1

from .delegation import Delegation
from .event import EncryptedDirectMessage, Event, EventKind
Expand Down Expand Up @@ -35,7 +37,7 @@ def from_npub(cls, npub: str):


class PrivateKey:
def __init__(self, raw_secret: bytes=None) -> None:
def __init__(self, raw_secret: Optional[bytes]=None) -> None:
if not raw_secret is None:
self.raw_secret = raw_secret
else:
Expand All @@ -51,17 +53,18 @@ def from_nsec(cls, nsec: str):
raw_secret = bech32.convertbits(data, 5, 8)[:-1]
return cls(bytes(raw_secret))

@classmethod
def from_hex(cls, hex: str):
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added from_hex

""" Load a PrivateKey from its bech32/nsec form """
return cls(bytes.fromhex(hex))

def bech32(self) -> str:
converted_bits = bech32.convertbits(self.raw_secret, 8, 5)
return bech32.bech32_encode("nsec", converted_bits, bech32.Encoding.BECH32)

def hex(self) -> str:
return self.raw_secret.hex()

def tweak_add(self, scalar: bytes) -> bytes:
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

got rid of this because it's not used, and not tested

sk = secp256k1.PrivateKey(self.raw_secret)
return sk.tweak_add(scalar)

def compute_shared_secret(self, public_key_hex: str) -> bytes:
pk = secp256k1.PublicKey(bytes.fromhex("02" + public_key_hex), True)
return pk.ecdh(self.raw_secret, hashfn=copy_x)
Expand All @@ -77,7 +80,7 @@ def encrypt_message(self, message: str, public_key_hex: str) -> str:
encrypted_message = encryptor.update(padded_data) + encryptor.finalize()

return f"{base64.b64encode(encrypted_message).decode()}?iv={base64.b64encode(iv).decode()}"

def encrypt_dm(self, dm: EncryptedDirectMessage) -> None:
dm.content = self.encrypt_message(message=dm.cleartext_content, public_key_hex=dm.recipient_pubkey)

Expand All @@ -104,7 +107,8 @@ def sign_message_hash(self, hash: bytes) -> str:

def sign_event(self, event: Event) -> None:
if event.kind == EventKind.ENCRYPTED_DIRECT_MESSAGE and event.content is None:
self.encrypt_dm(event)
edm = cast(EncryptedDirectMessage, event)
self.encrypt_dm(edm)
if event.public_key is None:
event.public_key = self.public_key.hex()
event.signature = self.sign_message_hash(bytes.fromhex(event.id))
Expand All @@ -116,7 +120,7 @@ def __eq__(self, other):
return self.raw_secret == other.raw_secret


def mine_vanity_key(prefix: str = None, suffix: str = None) -> PrivateKey:
def mine_vanity_key(prefix: Optional[str] = None, suffix: Optional[str] = None) -> PrivateKey:
if prefix is None and suffix is None:
raise ValueError("Expected at least one of 'prefix' or 'suffix' arguments")

Expand Down
10 changes: 1 addition & 9 deletions nostr/message_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,7 @@ def _process_message(self, message: str, url: str):
message_type = message_json[0]
if message_type == RelayMessageType.EVENT:
subscription_id = message_json[1]
e = message_json[2]
event = Event(
e["content"],
e["pubkey"],
e["created_at"],
e["kind"],
e["tags"],
e["sig"],
)
event = Event.from_dict(message_json[2])
with self.lock:
if not event.id in self._unique_events:
self.events.put(EventMessage(event, subscription_id, url))
Expand Down
32 changes: 18 additions & 14 deletions nostr/relay.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import json
import time
from dataclasses import dataclass
from queue import Queue
import logging

from dataclasses import dataclass, field
from threading import Lock
from typing import Optional
from websocket import WebSocketApp
Expand All @@ -11,6 +13,10 @@
from .message_type import RelayMessageType
from .subscription import Subscription


logger = logging.getLogger('nostr')


@dataclass
class RelayPolicy:
should_read: bool = True
Expand All @@ -34,9 +40,9 @@ class RelayProxyConnectionConfig:
class Relay:
url: str
message_pool: MessagePool
policy: RelayPolicy = RelayPolicy()
policy: RelayPolicy = field(default_factory=RelayPolicy)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

python 3.11 fix

proxy_config: Optional[RelayProxyConnectionConfig] = None
ssl_options: Optional[dict] = None
proxy_config: RelayProxyConnectionConfig = None

def __post_init__(self):
self.queue = Queue()
Expand All @@ -58,7 +64,7 @@ def __post_init__(self):
def connect(self):
self.ws.run_forever(
sslopt=self.ssl_options,
http_proxy_host=self.proxy_config.host if self.proxy_config is not None else None,
http_proxy_host=self.proxy_config.host if self.proxy_config is not None else None,
http_proxy_port=self.proxy_config.port if self.proxy_config is not None else None,
proxy_type=self.proxy_config.type if self.proxy_config is not None else None,
)
Expand Down Expand Up @@ -119,17 +125,23 @@ def _on_open(self, class_obj):

def _on_close(self, class_obj, status_code, message):
self.connected = False
logger.debug("Relay._on_open: url=%s", self.url)

def _on_close(self, class_obj, status_code, message):
logger.debug("Relay._on_close: url=%s, code=%s, message=%s", self.url,
status_code, message)

def _on_message(self, class_obj, message: str):
self.message_pool.add_message(message, self.url)

def _on_error(self, class_obj, error):
self.connected = False
self.error_counter += 1
if self.error_threshold and self.error_counter > self.error_threshold:
pass
else:
self.check_reconnect()
logger.debug("Relay._on_error: url=%s, error=%s", self.url, error)

def _is_valid_message(self, message: str) -> bool:
message = message.strip("\n")
Expand All @@ -149,15 +161,7 @@ def _is_valid_message(self, message: str) -> bool:
if subscription_id not in self.subscriptions:
return False

e = message_json[2]
event = Event(
e["content"],
e["pubkey"],
e["created_at"],
e["kind"],
e["tags"],
e["sig"],
)
event = Event.from_dict(message_json[2])
if not event.verify():
return False

Expand Down
14 changes: 8 additions & 6 deletions nostr/relay_manager.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
import time
import threading
from typing import Optional
from dataclasses import dataclass
from threading import Lock

Expand All @@ -26,20 +27,21 @@ def __post_init__(self):
self.lock: Lock = Lock()

def add_relay(
self,
url: str,
self,
url: str,
policy: RelayPolicy = RelayPolicy(),
ssl_options: dict = None,
proxy_config: RelayProxyConnectionConfig = None):
ssl_options: Optional[dict] = None,
proxy_config: Optional[RelayProxyConnectionConfig] = None):

relay = Relay(url, self.message_pool, policy, ssl_options, proxy_config)
relay = Relay(url, self.message_pool, policy, proxy_config, ssl_options)

with self.lock:
self.relays[url] = relay

threading.Thread(
target=relay.connect,
name=f"{relay.url}-thread"
name=f"{relay.url}-thread",
daemon=True
).start()

threading.Thread(
Expand Down
Loading