diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py new file mode 100644 index 0000000..4a17436 --- /dev/null +++ b/main.py @@ -0,0 +1,80 @@ +from nostr.client.client import NostrClient +from nostr.event import Event +from nostr.key import PublicKey +import asyncio +import threading + + +async def dm(): + print("This is an example NIP-04 DM flow") + pk = input("Enter your privatekey to post from (enter nothing for a random one): ") + + def callback(event: Event, decrypted_content): + """ + Callback to trigger when a DM is received. + """ + print( + f"From {event.public_key[:3]}..{event.public_key[-3:]}: {decrypted_content}" + ) + + client = NostrClient(privatekey_hex=pk) + await asyncio.sleep(1) + + t = threading.Thread( + target=client.get_dm, + args=( + client.public_key, + callback, + ), + ) + t.start() + + to_pubk_hex = ( + input("Enter other pubkey to post to (enter nothing to DM yourself): ") + or client.public_key.hex() + ) + print(f"Subscribing to DMs to {to_pubk_hex}") + while True: + msg = input("\nEnter message: ") + client.dm(msg, PublicKey(bytes.fromhex(to_pubk_hex))) + + +async def post(): + print("This posts and reads a nostr note") + pk = input("Enter your privatekey to post from (enter nothing for a random one): ") + + def callback(event: Event): + """ + Callback to trigger when post appers. + """ + print(f"From {event.public_key[:3]}..{event.public_key[-3:]}: {event.content}") + + sender_client = NostrClient(privatekey_hex=pk) + await asyncio.sleep(1) + + to_pubk_hex = ( + input("Enter other pubkey (enter nothing to read your own posts): ") + or sender_client.public_key.hex() + ) + print(f"Subscribing to posts by {to_pubk_hex}") + to_pubk = PublicKey(bytes.fromhex(to_pubk_hex)) + + t = threading.Thread( + target=sender_client.get_post, + args=( + to_pubk, + callback, + ), + ) + t.start() + + while True: + msg = input("\nEnter post: ") + sender_client.post(msg) + + +# write a DM and receive DMs +asyncio.run(dm()) + +# make a post and subscribe to posts +# asyncio.run(post()) diff --git a/nostr/client/__init__.py b/nostr/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nostr/client/cbc.py b/nostr/client/cbc.py new file mode 100644 index 0000000..a41dbc0 --- /dev/null +++ b/nostr/client/cbc.py @@ -0,0 +1,41 @@ + +from Cryptodome import Random +from Cryptodome.Cipher import AES + +plain_text = "This is the text to encrypts" + +# encrypted = "7mH9jq3K9xNfWqIyu9gNpUz8qBvGwsrDJ+ACExdV1DvGgY8q39dkxVKeXD7LWCDrPnoD/ZFHJMRMis8v9lwHfNgJut8EVTMuJJi8oTgJevOBXl+E+bJPwej9hY3k20rgCQistNRtGHUzdWyOv7S1tg==".encode() +# iv = "GzDzqOVShWu3Pl2313FBpQ==".encode() + +key = bytes.fromhex("3aa925cb69eb613e2928f8a18279c78b1dca04541dfd064df2eda66b59880795") + +BLOCK_SIZE = 16 + +class AESCipher(object): + """This class is compatible with crypto.createCipheriv('aes-256-cbc') + + """ + def __init__(self, key=None): + self.key = key + + def pad(self, data): + length = BLOCK_SIZE - (len(data) % BLOCK_SIZE) + return data + (chr(length) * length).encode() + + def unpad(self, data): + return data[: -(data[-1] if type(data[-1]) == int else ord(data[-1]))] + + def encrypt(self, plain_text): + cipher = AES.new(self.key, AES.MODE_CBC) + b = plain_text.encode("UTF-8") + return cipher.iv, cipher.encrypt(self.pad(b)) + + def decrypt(self, iv, enc_text): + cipher = AES.new(self.key, AES.MODE_CBC, iv=iv) + return self.unpad(cipher.decrypt(enc_text).decode("UTF-8")) + +if __name__ == "__main__": + aes = AESCipher(key=key) + iv, enc_text = aes.encrypt(plain_text) + dec_text = aes.decrypt(iv, enc_text) + print(dec_text) \ No newline at end of file diff --git a/nostr/client/client.py b/nostr/client/client.py new file mode 100644 index 0000000..c931b87 --- /dev/null +++ b/nostr/client/client.py @@ -0,0 +1,164 @@ +from typing import * +import ssl +import time +import json +import os +import base64 + +from ..event import Event +from ..relay_manager import RelayManager +from ..message_type import ClientMessageType +from ..key import PrivateKey, PublicKey + +from ..filter import Filter, Filters +from ..event import Event, EventKind +from ..relay_manager import RelayManager +from ..message_type import ClientMessageType + +# from aes import AESCipher +from . import cbc + + +class NostrClient: + relays = [ + "wss://lnbits.link/nostrrelay/client" + # "wss://nostr-pub.wellorder.net", + # "wss://nostr.zebedee.cloud", + # "wss://no.str.cr", + ] # ["wss://nostr.oxtr.dev"] # ["wss://relay.nostr.info"] "wss://nostr-pub.wellorder.net" "ws://91.237.88.218:2700", "wss://nostrrr.bublina.eu.org", ""wss://nostr-relay.freeberty.net"", , "wss://nostr.oxtr.dev", "wss://relay.nostr.info", "wss://nostr-pub.wellorder.net" , "wss://relayer.fiatjaf.com", "wss://nodestr.fmt.wiz.biz/", "wss://no.str.cr" + relay_manager = RelayManager() + private_key: PrivateKey + public_key: PublicKey + + def __init__(self, privatekey_hex: str = "", relays: List[str] = [], connect=True): + self.generate_keys(privatekey_hex) + + if len(relays): + self.relays = relays + + if connect: + for relay in self.relays: + self.relay_manager.add_relay(relay) + self.relay_manager.open_connections( + {"cert_reqs": ssl.CERT_NONE} + ) # NOTE: This disables ssl certificate verification + + def close(self): + self.relay_manager.close_connections() + + def generate_keys(self, privatekey_hex: str = None): + pk = bytes.fromhex(privatekey_hex) if privatekey_hex else None + self.private_key = PrivateKey(pk) + self.public_key = self.private_key.public_key + # print( + # f"Nostr private key: {self.private_key.hex()} ({self.private_key.bech32()})" + # ) + # print(f"Nostr public key: {self.public_key.hex()} ({self.public_key.bech32()})") + + def post(self, message: str): + event = Event(self.public_key.hex(), message, kind=EventKind.TEXT_NOTE) + self.private_key.sign_event(event) + message = json.dumps([ClientMessageType.EVENT, event.to_message()]) + # print("Publishing message:") + # print(message) + self.relay_manager.publish_message(message) + + def get_post(self, sender_publickey: PublicKey, callback_func=None): + filters = Filters( + [Filter(authors=[sender_publickey.hex()], kinds=[EventKind.TEXT_NOTE])] + ) + subscription_id = os.urandom(4).hex() + self.relay_manager.add_subscription(subscription_id, filters) + + request = [ClientMessageType.REQUEST, subscription_id] + request.extend(filters.to_json_array()) + message = json.dumps(request) + # print("Subscribing to events:") + # print(message) + self.relay_manager.publish_message(message) + + while True: + while self.relay_manager.message_pool.has_events(): + event_msg = self.relay_manager.message_pool.get_event() + print(event_msg.event.content) + if callback_func: + callback_func(event_msg.event) + time.sleep(0.1) + + def dm(self, message: str, to_pubkey: PublicKey): + + shared_secret = self.private_key.compute_shared_secret(to_pubkey.hex()) + + # print("shared secret: ", shared_secret.hex()) + # print("plain text:", message) + aes = cbc.AESCipher(key=shared_secret) + iv, enc_text = aes.encrypt(message) + # print("encrypt iv: ", iv) + content = f"{base64.b64encode(enc_text).decode('utf-8')}?iv={base64.b64encode(iv).decode('utf-8')}" + + event = Event( + self.public_key.hex(), + content, + tags=[["p", to_pubkey.hex()]], + kind=EventKind.ENCRYPTED_DIRECT_MESSAGE, + ) + self.private_key.sign_event(event) + event_message = json.dumps([ClientMessageType.EVENT, event.to_message()]) + # print("DM message:") + # print(event_message) + + time.sleep(1) + self.relay_manager.publish_message(event_message) + + def get_dm(self, sender_publickey: PublicKey, callback_func=None): + filters = Filters( + [ + Filter( + kinds=[EventKind.ENCRYPTED_DIRECT_MESSAGE], + pubkey_refs={"#p": [sender_publickey.hex()]}, + ) + ] + ) + subscription_id = os.urandom(4).hex() + self.relay_manager.add_subscription(subscription_id, filters) + + request = [ClientMessageType.REQUEST, subscription_id] + request.extend(filters.to_json_array()) + message = json.dumps(request) + # print("Subscribing to events:") + # print(message) + self.relay_manager.publish_message(message) + + while True: + while self.relay_manager.message_pool.has_events(): + event_msg = self.relay_manager.message_pool.get_event() + if "?iv=" in event_msg.event.content: + try: + shared_secret = self.private_key.compute_shared_secret( + event_msg.event.public_key + ) + # print("shared secret: ", shared_secret.hex()) + # print("plain text:", message) + aes = cbc.AESCipher(key=shared_secret) + enc_text_b64, iv_b64 = event_msg.event.content.split("?iv=") + iv = base64.decodebytes(iv_b64.encode("utf-8")) + enc_text = base64.decodebytes(enc_text_b64.encode("utf-8")) + # print("decrypt iv: ", iv) + dec_text = aes.decrypt(iv, enc_text) + # print(f"From {event_msg.event.public_key[:5]}...: {dec_text}") + if callback_func: + callback_func(event_msg.event, dec_text) + except: + pass + # else: + # print(f"\nFrom {event_msg.event.public_key[:5]}...: {event_msg.event.content}") + break + time.sleep(0.1) + + async def subscribe(self): + while True: + while self.relay_manager.message_pool.has_events(): + event_msg = self.relay_manager.message_pool.get_event() + print(event_msg.event.content) + break + time.sleep(0.1) diff --git a/nostr/filter.py b/nostr/filter.py index f4cb0a5..f119079 100644 --- a/nostr/filter.py +++ b/nostr/filter.py @@ -4,7 +4,6 @@ from .event import Event, EventKind - class Filter: """ NIP-01 filtering. @@ -16,20 +15,26 @@ 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: 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 = event_ids self.kinds = kinds self.authors = authors @@ -41,21 +46,19 @@ def __init__( self.tags = {} if self.event_refs: - self.add_arbitrary_tag('e', self.event_refs) + self.add_arbitrary_tag("e", self.event_refs) if self.pubkey_refs: - self.add_arbitrary_tag('p', self.pubkey_refs) - + self.add_arbitrary_tag("p", self.pubkey_refs) def add_arbitrary_tag(self, tag: str, values: list): """ - Filter on any arbitrary tag with explicit handling for NIP-01 and NIP-12 - single-letter tags. + Filter on any arbitrary tag with explicit handling for NIP-01 and NIP-12 + single-letter tags. """ - # NIP-01 'e' and 'p' tags and any NIP-12 single-letter tags must be prefixed with "#" + # NIP-01 'e' and 'p' tags and any NIP-12 single-letter tags must be prefixed with "#" tag_key = tag if len(tag) > 1 else f"#{tag}" self.tags[tag_key] = values - def matches(self, event: Event) -> bool: if self.event_ids is not None and event.id not in self.event_ids: return False @@ -67,7 +70,9 @@ def matches(self, event: Event) -> bool: return False if self.until is not None and event.created_at > self.until: return False - if (self.event_refs is not None or self.pubkey_refs is not None) and len(event.tags) == 0: + if (self.event_refs is not None or self.pubkey_refs is not None) and len( + event.tags + ) == 0: return False if self.tags: @@ -79,7 +84,7 @@ def matches(self, event: Event) -> bool: if f_tag not in e_tag_identifiers: # Event is missing a tag type that we're looking for return False - + # Multiple values within f_tag_values are treated as OR search; an Event # needs to match only one. # Note: an Event could have multiple entries of the same tag type @@ -94,12 +99,11 @@ def matches(self, event: Event) -> bool: return True - def to_json_object(self) -> dict: res = {} if self.event_ids is not None: res["ids"] = self.event_ids - if self.kinds is not None: + if self.kinds is not None: res["kinds"] = self.kinds if self.authors is not None: res["authors"] = self.authors @@ -115,9 +119,8 @@ def to_json_object(self) -> dict: return res - class Filters(UserList): - def __init__(self, initlist: "list[Filter]"=[]) -> None: + def __init__(self, initlist: "list[Filter]" = []) -> None: super().__init__(initlist) self.data: "list[Filter]" @@ -128,4 +131,4 @@ def match(self, event: Event): return False def to_json_array(self) -> list: - return [filter.to_json_object() for filter in self.data] \ No newline at end of file + return [filter.to_json_object() for filter in self.data] diff --git a/nostr/message_pool.py b/nostr/message_pool.py index ac46b24..d364cf2 100644 --- a/nostr/message_pool.py +++ b/nostr/message_pool.py @@ -4,22 +4,26 @@ from .message_type import RelayMessageType from .event import Event + class EventMessage: def __init__(self, event: Event, subscription_id: str, url: str) -> None: self.event = event self.subscription_id = subscription_id self.url = url + class NoticeMessage: def __init__(self, content: str, url: str) -> None: self.content = content self.url = url + class EndOfStoredEventsMessage: def __init__(self, subscription_id: str, url: str) -> None: self.subscription_id = subscription_id self.url = url + class MessagePool: def __init__(self) -> None: self.events: Queue[EventMessage] = Queue() @@ -27,7 +31,7 @@ def __init__(self) -> None: self.eose_notices: Queue[EndOfStoredEventsMessage] = Queue() self._unique_events: set = set() self.lock: Lock = Lock() - + def add_message(self, message: str, url: str): self._process_message(message, url) @@ -55,7 +59,14 @@ def _process_message(self, message: str, url: str): if message_type == RelayMessageType.EVENT: subscription_id = message_json[1] e = message_json[2] - event = Event(e['pubkey'], e['content'], e['created_at'], e['kind'], e['tags'], e['id'], e['sig']) + event = Event( + e["content"], + e["pubkey"], + e["created_at"], + e["kind"], + e["tags"], + e["sig"], + ) with self.lock: if not event.id in self._unique_events: self.events.put(EventMessage(event, subscription_id, url)) @@ -64,5 +75,3 @@ def _process_message(self, message: str, url: str): self.notices.put(NoticeMessage(message_json[1], url)) elif message_type == RelayMessageType.END_OF_STORED_EVENTS: self.eose_notices.put(EndOfStoredEventsMessage(message_json[1], url)) - - diff --git a/nostr/relay.py b/nostr/relay.py index 373a259..cc3d017 100644 --- a/nostr/relay.py +++ b/nostr/relay.py @@ -1,4 +1,5 @@ import json +import time from threading import Lock from websocket import WebSocketApp from .event import Event @@ -7,49 +8,69 @@ from .message_type import RelayMessageType from .subscription import Subscription + class RelayPolicy: - def __init__(self, should_read: bool=True, should_write: bool=True) -> None: + def __init__(self, should_read: bool = True, should_write: bool = True) -> None: self.should_read = should_read self.should_write = should_write def to_json_object(self) -> dict[str, bool]: - return { - "read": self.should_read, - "write": self.should_write - } + return {"read": self.should_read, "write": self.should_write} + class Relay: def __init__( - self, - url: str, - policy: RelayPolicy, - message_pool: MessagePool, - subscriptions: dict[str, Subscription]={}) -> None: + self, + url: str, + policy: RelayPolicy, + message_pool: MessagePool, + subscriptions: dict[str, Subscription] = {}, + ) -> None: self.url = url self.policy = policy self.message_pool = message_pool self.subscriptions = subscriptions + self.connected: bool = False + self.reconnect: bool = True + self.error_counter: int = 0 + self.error_threshold: int = 0 + self.ssl_options: dict = {} + self.proxy: dict = {} self.lock = Lock() self.ws = WebSocketApp( url, on_open=self._on_open, on_message=self._on_message, on_error=self._on_error, - on_close=self._on_close) + on_close=self._on_close, + ) - def connect(self, ssl_options: dict=None, proxy: dict=None): + def connect(self, ssl_options: dict = None, proxy: dict = None): + self.ssl_options = ssl_options + self.proxy = proxy self.ws.run_forever( sslopt=ssl_options, - http_proxy_host=None if proxy is None else proxy.get('host'), - http_proxy_port=None if proxy is None else proxy.get('port'), - proxy_type=None if proxy is None else proxy.get('type') + http_proxy_host=None if proxy is None else proxy.get("host"), + http_proxy_port=None if proxy is None else proxy.get("port"), + proxy_type=None if proxy is None else proxy.get("type"), ) def close(self): self.ws.close() + def check_reconnect(self): + try: + self.close() + except: + pass + self.connected = False + if self.reconnect: + time.sleep(1) + self.connect(self.ssl_options, self.proxy) + def publish(self, message: str): - self.ws.send(message) + if self.connected: + self.ws.send(message) def add_subscription(self, id, filters: Filters): with self.lock: @@ -68,25 +89,35 @@ def to_json_object(self) -> dict: return { "url": self.url, "policy": self.policy.to_json_object(), - "subscriptions": [subscription.to_json_object() for subscription in self.subscriptions.values()] + "subscriptions": [ + subscription.to_json_object() + for subscription in self.subscriptions.values() + ], } def _on_open(self, class_obj): + self.connected = True pass def _on_close(self, class_obj, status_code, message): + self.connected = False pass def _on_message(self, class_obj, message: str): if self._is_valid_message(message): self.message_pool.add_message(message, self.url) - + def _on_error(self, class_obj, error): - pass + self.connected = False + self.error_counter += 1 + if self.error_threshold and self.error_counter > self.error_threshold: + pass + else: + self.check_reconnect() def _is_valid_message(self, message: str) -> bool: message = message.strip("\n") - if not message or message[0] != '[' or message[-1] != ']': + if not message or message[0] != "[" or message[-1] != "]": return False message_json = json.loads(message) @@ -96,21 +127,28 @@ def _is_valid_message(self, message: str) -> bool: if message_type == RelayMessageType.EVENT: if not len(message_json) == 3: return False - + subscription_id = message_json[1] with self.lock: if subscription_id not in self.subscriptions: return False e = message_json[2] - event = Event(e['pubkey'], e['content'], e['created_at'], e['kind'], e['tags'], e['id'], e['sig']) + event = Event( + e["content"], + e["pubkey"], + e["created_at"], + e["kind"], + e["tags"], + e["sig"], + ) if not event.verify(): return False with self.lock: subscription = self.subscriptions[subscription_id] - if not subscription.filters.match(event): + if subscription.filters and not subscription.filters.match(event): return False return True diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..4e433ab --- /dev/null +++ b/poetry.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Poetry and should not be changed by hand. +package = [] + +[metadata] +lock-version = "2.0" +python-versions = "*" +content-hash = "115cf985d932e9bf5f540555bbdd75decbb62cac81e399375fc19f6277f8c1d8" diff --git a/pyproject.toml b/pyproject.toml index 417a873..9d733ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,9 @@ +[tool.poetry] +name = "python-nostr" +version = "0.1.0" +description = "" +authors = ["Your Name "] + [build-system] requires = ["setuptools", "setuptools-scm"] build-backend = "setuptools.build_meta" @@ -33,4 +39,4 @@ write_to = "nostr/_version.py" test = [ "pytest >=7.2.0", "pytest-cov[all]" -] \ No newline at end of file +]