diff --git a/cashu/wallet/cli.py b/cashu/wallet/cli.py index 09ff7d9..d454181 100644 --- a/cashu/wallet/cli.py +++ b/cashu/wallet/cli.py @@ -39,6 +39,9 @@ from cashu.wallet.crud import ( ) from cashu.wallet.wallet import Wallet as Wallet +from nostr.client.client import NostrClient +from nostr.key import PublicKey + async def init_wallet(wallet: Wallet): """Performs migrations and loads proofs from db.""" @@ -433,3 +436,70 @@ async def info(ctx): print(f"Socks proxy: {SOCKS_HOST}:{SOCKS_PORT}") print(f"Mint URL: {MINT_URL}") return + + +@cli.command("nostr", help="Receive tokens via nostr.") +@click.pass_context +@coro +async def nostr(ctx): + wallet: Wallet = ctx.obj["WALLET"] + await wallet.load_mint() + client = NostrClient( + privatekey_hex="bfc6e7b0b998645d45aa451a3b9a3174bfe696fba78e86a86637a16f43e6c683" + ) + print(f"Your nostr public key: {client.public_key.hex()}") + await asyncio.sleep(2) + + def get_token_callback(token): + try: + proofs = [Proof(**p) for p in json.loads(base64.urlsafe_b64decode(token))] + wallet: Wallet = ctx.obj["WALLET"] + asyncio.run(wallet.redeem(proofs)) + wallet.status() + except Exception as e: + pass + + import threading + + t = threading.Thread( + target=client.get_dm, + args=( + client.public_key, + get_token_callback, + ), + name="Nostr DM", + ) + t.start() + + +@cli.command("nostrsend", help="Send tokens via nostr.") +@click.argument("amount", type=int) +@click.argument( + "pubkey", + type=str, + default="13395e6d975825cb811549b4b6ba6695c7ea8f75e1f3658d6cee2bee243195c3", +) +@click.pass_context +@coro +async def nostrsend(ctx, amount: int, pubkey: str): + wallet: Wallet = ctx.obj["WALLET"] + await wallet.load_mint() + wallet.status() + _, send_proofs = await wallet.split_to_send( + wallet.proofs, amount, set_reserved=True + ) + token = await wallet.serialize_proofs(send_proofs) + + from random import randrange + + # token = f"Token {randrange(1000)}" + print(token) + wallet.status() + + client = NostrClient( + privatekey_hex="bfc6e7b0b598645d45aa451a3b9a3174bfe696fba78e86a86637a16f4ee6d683" + ) + await asyncio.sleep(1) + client.dm(token, PublicKey(bytes.fromhex(pubkey))) + print("Sent") + client.close() diff --git a/nostr/__init__.py b/nostr/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nostr/bech32.py b/nostr/bech32.py new file mode 100644 index 0000000..b068de7 --- /dev/null +++ b/nostr/bech32.py @@ -0,0 +1,137 @@ +# Copyright (c) 2017, 2020 Pieter Wuille +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +"""Reference implementation for Bech32/Bech32m and segwit addresses.""" + + +from enum import Enum + +class Encoding(Enum): + """Enumeration type to list the various supported encodings.""" + BECH32 = 1 + BECH32M = 2 + +CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" +BECH32M_CONST = 0x2bc830a3 + +def bech32_polymod(values): + """Internal function that computes the Bech32 checksum.""" + generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] + chk = 1 + for value in values: + top = chk >> 25 + chk = (chk & 0x1ffffff) << 5 ^ value + for i in range(5): + chk ^= generator[i] if ((top >> i) & 1) else 0 + return chk + + +def bech32_hrp_expand(hrp): + """Expand the HRP into values for checksum computation.""" + return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp] + + +def bech32_verify_checksum(hrp, data): + """Verify a checksum given HRP and converted data characters.""" + const = bech32_polymod(bech32_hrp_expand(hrp) + data) + if const == 1: + return Encoding.BECH32 + if const == BECH32M_CONST: + return Encoding.BECH32M + return None + +def bech32_create_checksum(hrp, data, spec): + """Compute the checksum values given HRP and data.""" + values = bech32_hrp_expand(hrp) + data + const = BECH32M_CONST if spec == Encoding.BECH32M else 1 + polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ const + return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)] + + +def bech32_encode(hrp, data, spec): + """Compute a Bech32 string given HRP and data values.""" + combined = data + bech32_create_checksum(hrp, data, spec) + return hrp + '1' + ''.join([CHARSET[d] for d in combined]) + +def bech32_decode(bech): + """Validate a Bech32/Bech32m string, and determine HRP and data.""" + if ((any(ord(x) < 33 or ord(x) > 126 for x in bech)) or + (bech.lower() != bech and bech.upper() != bech)): + return (None, None, None) + bech = bech.lower() + pos = bech.rfind('1') + if pos < 1 or pos + 7 > len(bech) or len(bech) > 90: + return (None, None, None) + if not all(x in CHARSET for x in bech[pos+1:]): + return (None, None, None) + hrp = bech[:pos] + data = [CHARSET.find(x) for x in bech[pos+1:]] + spec = bech32_verify_checksum(hrp, data) + if spec is None: + return (None, None, None) + return (hrp, data[:-6], spec) + +def convertbits(data, frombits, tobits, pad=True): + """General power-of-2 base conversion.""" + acc = 0 + bits = 0 + ret = [] + maxv = (1 << tobits) - 1 + max_acc = (1 << (frombits + tobits - 1)) - 1 + for value in data: + if value < 0 or (value >> frombits): + return None + acc = ((acc << frombits) | value) & max_acc + bits += frombits + while bits >= tobits: + bits -= tobits + ret.append((acc >> bits) & maxv) + if pad: + if bits: + ret.append((acc << (tobits - bits)) & maxv) + elif bits >= frombits or ((acc << (tobits - bits)) & maxv): + return None + return ret + + +def decode(hrp, addr): + """Decode a segwit address.""" + hrpgot, data, spec = bech32_decode(addr) + if hrpgot != hrp: + return (None, None) + decoded = convertbits(data[1:], 5, 8, False) + if decoded is None or len(decoded) < 2 or len(decoded) > 40: + return (None, None) + if data[0] > 16: + return (None, None) + if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32: + return (None, None) + if data[0] == 0 and spec != Encoding.BECH32 or data[0] != 0 and spec != Encoding.BECH32M: + return (None, None) + return (data[0], decoded) + + +def encode(hrp, witver, witprog): + """Encode a segwit address.""" + spec = Encoding.BECH32 if witver == 0 else Encoding.BECH32M + ret = bech32_encode(hrp, [witver] + convertbits(witprog, 8, 5), spec) + if decode(hrp, ret) == (None, None): + return None + return ret diff --git a/nostr/client/__init__.py b/nostr/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nostr/client/aes.py b/nostr/client/aes.py new file mode 100644 index 0000000..9d20224 --- /dev/null +++ b/nostr/client/aes.py @@ -0,0 +1,86 @@ +import base64 +import getpass +from hashlib import md5 + +from Cryptodome import Random +from Cryptodome.Cipher import AES + +BLOCK_SIZE = 16 + +import getpass + + +class AESCipher(object): + """This class is compatible with crypto-js/aes.js + + Encrypt and decrypt in Javascript using: + import AES from "crypto-js/aes.js"; + import Utf8 from "crypto-js/enc-utf8.js"; + AES.encrypt(decrypted, password).toString() + AES.decrypt(encrypted, password).toString(Utf8); + + """ + + def __init__(self, key=None, description=""): + self.key = key + self.description = description + " " + + 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]))] + + @property + def passphrase(self): + passphrase = self.key if self.key is not None else None + if passphrase is None: + passphrase = getpass.getpass(f"Enter {self.description}password:") + return passphrase + + def bytes_to_key(self, data, salt, output=48): + # extended from https://gist.github.com/gsakkis/4546068 + assert len(salt) == 8, len(salt) + data += salt + key = md5(data).digest() + final_key = key + while len(final_key) < output: + key = md5(key + data).digest() + final_key += key + return final_key[:output] + + def decrypt(self, encrypted: str) -> str: # type: ignore + """Decrypts a string using AES-256-CBC.""" + passphrase = self.passphrase + encrypted = base64.b64decode(encrypted) # type: ignore + assert encrypted[0:8] == b"Salted__" + salt = encrypted[8:16] + key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16) + key = key_iv[:32] + iv = key_iv[32:] + aes = AES.new(key, AES.MODE_CBC, iv) + try: + return self.unpad(aes.decrypt(encrypted[16:])).decode() # type: ignore + except UnicodeDecodeError: + raise ValueError("Wrong passphrase") + + def encrypt(self, message: bytes) -> str: + passphrase = self.passphrase + salt = Random.new().read(8) + key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16) + key = key_iv[:32] + iv = key_iv[32:] + aes = AES.new(key, AES.MODE_CBC, iv) + return base64.b64encode( + b"Salted__" + salt + aes.encrypt(self.pad(message)) + ).decode() + + +# # if this file is executed directly, ask for a macaroon and encrypt it +# if __name__ == "__main__": +# macaroon = input("Enter macaroon: ") +# macaroon = load_macaroon(macaroon) +# macaroon = AESCipher(description="encryption").encrypt(macaroon.encode()) +# logger.info("Encrypted macaroon:") +# logger.info(macaroon) 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..74aed9d --- /dev/null +++ b/nostr/client/client.py @@ -0,0 +1,165 @@ +from typing import * +import ssl +import time +import json +import os +import base64 + +from nostr.event import Event +from nostr.relay_manager import RelayManager +from nostr.message_type import ClientMessageType +from nostr.key import PrivateKey, PublicKey + +from nostr.filter import Filter, Filters +from nostr.event import Event, EventKind +from nostr.relay_manager import RelayManager +from nostr.message_type import ClientMessageType + +# from aes import AESCipher +from . import cbc + + +class NostrClient: + relays = [ + "wss://nostr.zebedee.cloud" + ] # ["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/" + relay_manager = RelayManager() + private_key: PrivateKey + public_key: PublicKey + + def __init__(self, privatekey_hex: str = "", relays: List[str] = []): + self.generate_keys(privatekey_hex) + + if len(relays): + self.relays = relays + + 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"Private key: {self.private_key.bech32()} ({self.private_key.hex()})") + print(f"Public key: {self.public_key.bech32()} ({self.public_key.hex()})") + + def post(self, message: str): + event = Event(self.public_key.hex(), message, kind=EventKind.TEXT_NOTE) + event.sign(self.private_key.hex()) + message = json.dumps([ClientMessageType.EVENT, event.to_json_object()]) + print("Publishing message:") + print(message) + self.relay_manager.publish_message(message) + + def get_post(self, sender_publickey: PublicKey): + 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) + + message_received = False + while True: + while self.relay_manager.message_pool.has_events(): + event_msg = self.relay_manager.message_pool.get_event() + print(event_msg.event.content) + message_received = True + break + else: + 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, + ) + event.sign(self.private_key.hex()) + event_message = json.dumps([ClientMessageType.EVENT, event.to_json_object()]) + # 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], + tags={"#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(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/event.py b/nostr/event.py new file mode 100644 index 0000000..450893e --- /dev/null +++ b/nostr/event.py @@ -0,0 +1,65 @@ +import time +import json +from enum import IntEnum +from secp256k1 import PrivateKey, PublicKey +from hashlib import sha256 + +class EventKind(IntEnum): + SET_METADATA = 0 + TEXT_NOTE = 1 + RECOMMEND_RELAY = 2 + CONTACTS = 3 + ENCRYPTED_DIRECT_MESSAGE = 4 + DELETE = 5 + +class Event(): + def __init__( + self, + public_key: str, + content: str, + created_at: int=int(time.time()), + kind: int=EventKind.TEXT_NOTE, + tags: "list[list[str]]"=[], + id: str=None, + signature: str=None) -> None: + if not isinstance(content, str): + raise TypeError("Argument 'content' must be of type str") + + self.id = id if not id is None else Event.compute_id(public_key, created_at, kind, tags, content) + self.public_key = public_key + self.content = content + self.created_at = created_at + self.kind = kind + self.tags = tags + self.signature = signature + + @staticmethod + def serialize(public_key: str, created_at: int, kind: int, tags: "list[list[str]]", content: str) -> bytes: + data = [0, public_key, created_at, kind, tags, content] + data_str = json.dumps(data, separators=(',', ':'), ensure_ascii=False) + return data_str.encode() + + @staticmethod + def compute_id(public_key: str, created_at: int, kind: int, tags: "list[list[str]]", content: str) -> str: + return sha256(Event.serialize(public_key, created_at, kind, tags, content)).hexdigest() + + def sign(self, private_key_hex: str) -> None: + sk = PrivateKey(bytes.fromhex(private_key_hex)) + sig = sk.schnorr_sign(bytes.fromhex(self.id), None, raw=True) + self.signature = sig.hex() + + def verify(self) -> bool: + pub_key = PublicKey(bytes.fromhex("02" + self.public_key), True) # add 02 for schnorr (bip340) + event_id = Event.compute_id(self.public_key, self.created_at, self.kind, self.tags, self.content) + return pub_key.schnorr_verify(bytes.fromhex(event_id), bytes.fromhex(self.signature), None, raw=True) + + def to_json_object(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 + } diff --git a/nostr/filter.py b/nostr/filter.py new file mode 100644 index 0000000..32b94a3 --- /dev/null +++ b/nostr/filter.py @@ -0,0 +1,81 @@ +from collections import UserList +from .event import Event + + +class Filter: + def __init__( + self, + ids: "list[str]" = None, + kinds: "list[int]" = None, + authors: "list[str]" = None, + since: int = None, + until: int = None, + tags: "dict[str, list[str]]" = None, + limit: int = None, + ) -> None: + self.IDs = ids + self.kinds = kinds + self.authors = authors + self.since = since + self.until = until + self.tags = tags + self.limit = limit + + def matches(self, event: Event) -> bool: + if self.IDs != None and event.id not in self.IDs: + return False + if self.kinds != None and event.kind not in self.kinds: + return False + if self.authors != None and event.public_key not in self.authors: + return False + if self.since != None and event.created_at < self.since: + return False + if self.until != None and event.created_at > self.until: + return False + if self.tags != None and len(event.tags) == 0: + return False + if self.tags != None: + e_tag_identifiers = [e_tag[0] for e_tag in event.tags] + for f_tag, f_tag_values in self.tags.items(): + if f_tag[1:] not in e_tag_identifiers: + return False + for e_tag in event.tags: + if e_tag[1] not in f_tag_values: + return False + + return True + + def to_json_object(self) -> dict: + res = {} + if self.IDs != None: + res["ids"] = self.IDs + if self.kinds != None: + res["kinds"] = self.kinds + if self.authors != None: + res["authors"] = self.authors + if self.since != None: + res["since"] = self.since + if self.until != None: + res["until"] = self.until + if self.tags != None: + for tag, values in self.tags.items(): + res[tag] = values + if self.limit != None: + res["limit"] = self.limit + + return res + + +class Filters(UserList): + def __init__(self, initlist: "list[Filter]" = []) -> None: + super().__init__(initlist) + self.data: "list[Filter]" + + def match(self, event: Event): + for filter in self.data: + if filter.matches(event): + return True + return False + + def to_json_array(self) -> list: + return [filter.to_json_object() for filter in self.data] diff --git a/nostr/key.py b/nostr/key.py new file mode 100644 index 0000000..9449c00 --- /dev/null +++ b/nostr/key.py @@ -0,0 +1,86 @@ +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 . import bech32 + +class PublicKey: + def __init__(self, raw_bytes: bytes) -> None: + self.raw_bytes = raw_bytes + + def bech32(self) -> str: + converted_bits = bech32.convertbits(self.raw_bytes, 8, 5) + return bech32.bech32_encode("npub", converted_bits, bech32.Encoding.BECH32) + + def hex(self) -> str: + return self.raw_bytes.hex() + + def verify_signed_message_hash(self, hash: str, sig: str) -> bool: + pk = secp256k1.PublicKey(b"\x02" + self.raw_bytes, True) + return pk.schnorr_verify(bytes.fromhex(hash), bytes.fromhex(sig), None, True) + +class PrivateKey: + def __init__(self, raw_secret: bytes=None) -> None: + if not raw_secret is None: + self.raw_secret = raw_secret + else: + self.raw_secret = secrets.token_bytes(32) + + sk = secp256k1.PrivateKey(self.raw_secret) + self.public_key = PublicKey(sk.pubkey.serialize()[1:]) + + 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: + 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) + + def encrypt_message(self, message: str, public_key_hex: str) -> str: + padder = padding.PKCS7(128).padder() + padded_data = padder.update(message.encode()) + padder.finalize() + + iv = secrets.token_bytes(16) + cipher = Cipher(algorithms.AES(self.compute_shared_secret(public_key_hex)), modes.CBC(iv)) + + encryptor = cipher.encryptor() + encrypted_message = encryptor.update(padded_data) + encryptor.finalize() + + return f"{base64.b64encode(encrypted_message).decode()}?iv={base64.b64encode(iv).decode()}" + + def decrypt_message(self, encoded_message: str, public_key_hex: str) -> str: + encoded_data = encoded_message.split('?iv=') + encoded_content, encoded_iv = encoded_data[0], encoded_data[1] + + iv = base64.b64decode(encoded_iv) + cipher = Cipher(algorithms.AES(self.compute_shared_secret(public_key_hex)), modes.CBC(iv)) + encrypted_content = base64.b64decode(encoded_content) + + decryptor = cipher.decryptor() + decrypted_message = decryptor.update(encrypted_content) + decryptor.finalize() + + unpadder = padding.PKCS7(128).unpadder() + unpadded_data = unpadder.update(decrypted_message) + unpadder.finalize() + + return unpadded_data.decode() + + def sign_message_hash(self, hash: bytes) -> str: + sk = secp256k1.PrivateKey(self.raw_secret) + sig = sk.schnorr_sign(hash, None, raw=True) + return sig.hex() + +ffi = FFI() +@ffi.callback("int (unsigned char *, const unsigned char *, const unsigned char *, void *)") +def copy_x(output, x32, y32, data): + ffi.memmove(output, x32, 32) + return 1 \ No newline at end of file diff --git a/nostr/message_pool.py b/nostr/message_pool.py new file mode 100644 index 0000000..472e31e --- /dev/null +++ b/nostr/message_pool.py @@ -0,0 +1,78 @@ +import json +from queue import Queue +from threading import Lock +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() + self.notices: Queue[NoticeMessage] = Queue() + 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) + + def get_event(self): + return self.events.get() + + def get_notice(self): + return self.notices.get() + + def get_eose_notice(self): + return self.eose_notices.get() + + def has_events(self): + return self.events.qsize() > 0 + + def has_notices(self): + return self.notices.qsize() > 0 + + def has_eose_notices(self): + return self.eose_notices.qsize() > 0 + + def _process_message(self, message: str, url: str): + message_json = json.loads(message) + message_type = message_json[0] + 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"], + ) + with self.lock: + if not event.id in self._unique_events: + self.events.put(EventMessage(event, subscription_id, url)) + self._unique_events.add(event.id) + elif message_type == RelayMessageType.NOTICE: + 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/message_type.py b/nostr/message_type.py new file mode 100644 index 0000000..3f5206b --- /dev/null +++ b/nostr/message_type.py @@ -0,0 +1,15 @@ +class ClientMessageType: + EVENT = "EVENT" + REQUEST = "REQ" + CLOSE = "CLOSE" + +class RelayMessageType: + EVENT = "EVENT" + NOTICE = "NOTICE" + END_OF_STORED_EVENTS = "EOSE" + + @staticmethod + def is_valid(type: str) -> bool: + if type == RelayMessageType.EVENT or type == RelayMessageType.NOTICE or type == RelayMessageType.END_OF_STORED_EVENTS: + return True + return False \ No newline at end of file diff --git a/nostr/nostr/__init__.py b/nostr/nostr/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nostr/nostr/bech32.py b/nostr/nostr/bech32.py new file mode 100644 index 0000000..b068de7 --- /dev/null +++ b/nostr/nostr/bech32.py @@ -0,0 +1,137 @@ +# Copyright (c) 2017, 2020 Pieter Wuille +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +"""Reference implementation for Bech32/Bech32m and segwit addresses.""" + + +from enum import Enum + +class Encoding(Enum): + """Enumeration type to list the various supported encodings.""" + BECH32 = 1 + BECH32M = 2 + +CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" +BECH32M_CONST = 0x2bc830a3 + +def bech32_polymod(values): + """Internal function that computes the Bech32 checksum.""" + generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] + chk = 1 + for value in values: + top = chk >> 25 + chk = (chk & 0x1ffffff) << 5 ^ value + for i in range(5): + chk ^= generator[i] if ((top >> i) & 1) else 0 + return chk + + +def bech32_hrp_expand(hrp): + """Expand the HRP into values for checksum computation.""" + return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp] + + +def bech32_verify_checksum(hrp, data): + """Verify a checksum given HRP and converted data characters.""" + const = bech32_polymod(bech32_hrp_expand(hrp) + data) + if const == 1: + return Encoding.BECH32 + if const == BECH32M_CONST: + return Encoding.BECH32M + return None + +def bech32_create_checksum(hrp, data, spec): + """Compute the checksum values given HRP and data.""" + values = bech32_hrp_expand(hrp) + data + const = BECH32M_CONST if spec == Encoding.BECH32M else 1 + polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ const + return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)] + + +def bech32_encode(hrp, data, spec): + """Compute a Bech32 string given HRP and data values.""" + combined = data + bech32_create_checksum(hrp, data, spec) + return hrp + '1' + ''.join([CHARSET[d] for d in combined]) + +def bech32_decode(bech): + """Validate a Bech32/Bech32m string, and determine HRP and data.""" + if ((any(ord(x) < 33 or ord(x) > 126 for x in bech)) or + (bech.lower() != bech and bech.upper() != bech)): + return (None, None, None) + bech = bech.lower() + pos = bech.rfind('1') + if pos < 1 or pos + 7 > len(bech) or len(bech) > 90: + return (None, None, None) + if not all(x in CHARSET for x in bech[pos+1:]): + return (None, None, None) + hrp = bech[:pos] + data = [CHARSET.find(x) for x in bech[pos+1:]] + spec = bech32_verify_checksum(hrp, data) + if spec is None: + return (None, None, None) + return (hrp, data[:-6], spec) + +def convertbits(data, frombits, tobits, pad=True): + """General power-of-2 base conversion.""" + acc = 0 + bits = 0 + ret = [] + maxv = (1 << tobits) - 1 + max_acc = (1 << (frombits + tobits - 1)) - 1 + for value in data: + if value < 0 or (value >> frombits): + return None + acc = ((acc << frombits) | value) & max_acc + bits += frombits + while bits >= tobits: + bits -= tobits + ret.append((acc >> bits) & maxv) + if pad: + if bits: + ret.append((acc << (tobits - bits)) & maxv) + elif bits >= frombits or ((acc << (tobits - bits)) & maxv): + return None + return ret + + +def decode(hrp, addr): + """Decode a segwit address.""" + hrpgot, data, spec = bech32_decode(addr) + if hrpgot != hrp: + return (None, None) + decoded = convertbits(data[1:], 5, 8, False) + if decoded is None or len(decoded) < 2 or len(decoded) > 40: + return (None, None) + if data[0] > 16: + return (None, None) + if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32: + return (None, None) + if data[0] == 0 and spec != Encoding.BECH32 or data[0] != 0 and spec != Encoding.BECH32M: + return (None, None) + return (data[0], decoded) + + +def encode(hrp, witver, witprog): + """Encode a segwit address.""" + spec = Encoding.BECH32 if witver == 0 else Encoding.BECH32M + ret = bech32_encode(hrp, [witver] + convertbits(witprog, 8, 5), spec) + if decode(hrp, ret) == (None, None): + return None + return ret diff --git a/nostr/nostr/client/__init__.py b/nostr/nostr/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nostr/nostr/client/aes.py b/nostr/nostr/client/aes.py new file mode 100644 index 0000000..9d20224 --- /dev/null +++ b/nostr/nostr/client/aes.py @@ -0,0 +1,86 @@ +import base64 +import getpass +from hashlib import md5 + +from Cryptodome import Random +from Cryptodome.Cipher import AES + +BLOCK_SIZE = 16 + +import getpass + + +class AESCipher(object): + """This class is compatible with crypto-js/aes.js + + Encrypt and decrypt in Javascript using: + import AES from "crypto-js/aes.js"; + import Utf8 from "crypto-js/enc-utf8.js"; + AES.encrypt(decrypted, password).toString() + AES.decrypt(encrypted, password).toString(Utf8); + + """ + + def __init__(self, key=None, description=""): + self.key = key + self.description = description + " " + + 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]))] + + @property + def passphrase(self): + passphrase = self.key if self.key is not None else None + if passphrase is None: + passphrase = getpass.getpass(f"Enter {self.description}password:") + return passphrase + + def bytes_to_key(self, data, salt, output=48): + # extended from https://gist.github.com/gsakkis/4546068 + assert len(salt) == 8, len(salt) + data += salt + key = md5(data).digest() + final_key = key + while len(final_key) < output: + key = md5(key + data).digest() + final_key += key + return final_key[:output] + + def decrypt(self, encrypted: str) -> str: # type: ignore + """Decrypts a string using AES-256-CBC.""" + passphrase = self.passphrase + encrypted = base64.b64decode(encrypted) # type: ignore + assert encrypted[0:8] == b"Salted__" + salt = encrypted[8:16] + key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16) + key = key_iv[:32] + iv = key_iv[32:] + aes = AES.new(key, AES.MODE_CBC, iv) + try: + return self.unpad(aes.decrypt(encrypted[16:])).decode() # type: ignore + except UnicodeDecodeError: + raise ValueError("Wrong passphrase") + + def encrypt(self, message: bytes) -> str: + passphrase = self.passphrase + salt = Random.new().read(8) + key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16) + key = key_iv[:32] + iv = key_iv[32:] + aes = AES.new(key, AES.MODE_CBC, iv) + return base64.b64encode( + b"Salted__" + salt + aes.encrypt(self.pad(message)) + ).decode() + + +# # if this file is executed directly, ask for a macaroon and encrypt it +# if __name__ == "__main__": +# macaroon = input("Enter macaroon: ") +# macaroon = load_macaroon(macaroon) +# macaroon = AESCipher(description="encryption").encrypt(macaroon.encode()) +# logger.info("Encrypted macaroon:") +# logger.info(macaroon) diff --git a/nostr/nostr/client/cbc.py b/nostr/nostr/client/cbc.py new file mode 100644 index 0000000..a41dbc0 --- /dev/null +++ b/nostr/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/nostr/client/client.py b/nostr/nostr/client/client.py new file mode 100644 index 0000000..341778e --- /dev/null +++ b/nostr/nostr/client/client.py @@ -0,0 +1,165 @@ +from typing import * +import ssl +import time +import json +import os +import base64 + +from nostr.event import Event +from nostr.relay_manager import RelayManager +from nostr.message_type import ClientMessageType +from nostr.key import PrivateKey, PublicKey + +from nostr.filter import Filter, Filters +from nostr.event import Event, EventKind +from nostr.relay_manager import RelayManager +from nostr.message_type import ClientMessageType + +# from aes import AESCipher +from . import cbc + + +class NostrClient: + relays = [ + "wss://nostr.zebedee.cloud" + ] # ["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] = []): + self.generate_keys(privatekey_hex) + + if len(relays): + self.relays = relays + + 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"Private key: {self.private_key.bech32()} ({self.private_key.hex()})") + print(f"Public key: {self.public_key.bech32()} ({self.public_key.hex()})") + + def post(self, message: str): + event = Event(self.public_key.hex(), message, kind=EventKind.TEXT_NOTE) + event.sign(self.private_key.hex()) + message = json.dumps([ClientMessageType.EVENT, event.to_json_object()]) + # print("Publishing message:") + # print(message) + self.relay_manager.publish_message(message) + + def get_post(self, sender_publickey: PublicKey): + 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) + + message_received = False + while True: + while self.relay_manager.message_pool.has_events(): + event_msg = self.relay_manager.message_pool.get_event() + print(event_msg.event.content) + message_received = True + break + else: + 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, + ) + event.sign(self.private_key.hex()) + event_message = json.dumps([ClientMessageType.EVENT, event.to_json_object()]) + # 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], + tags={"#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/nostr/event.py b/nostr/nostr/event.py new file mode 100644 index 0000000..450893e --- /dev/null +++ b/nostr/nostr/event.py @@ -0,0 +1,65 @@ +import time +import json +from enum import IntEnum +from secp256k1 import PrivateKey, PublicKey +from hashlib import sha256 + +class EventKind(IntEnum): + SET_METADATA = 0 + TEXT_NOTE = 1 + RECOMMEND_RELAY = 2 + CONTACTS = 3 + ENCRYPTED_DIRECT_MESSAGE = 4 + DELETE = 5 + +class Event(): + def __init__( + self, + public_key: str, + content: str, + created_at: int=int(time.time()), + kind: int=EventKind.TEXT_NOTE, + tags: "list[list[str]]"=[], + id: str=None, + signature: str=None) -> None: + if not isinstance(content, str): + raise TypeError("Argument 'content' must be of type str") + + self.id = id if not id is None else Event.compute_id(public_key, created_at, kind, tags, content) + self.public_key = public_key + self.content = content + self.created_at = created_at + self.kind = kind + self.tags = tags + self.signature = signature + + @staticmethod + def serialize(public_key: str, created_at: int, kind: int, tags: "list[list[str]]", content: str) -> bytes: + data = [0, public_key, created_at, kind, tags, content] + data_str = json.dumps(data, separators=(',', ':'), ensure_ascii=False) + return data_str.encode() + + @staticmethod + def compute_id(public_key: str, created_at: int, kind: int, tags: "list[list[str]]", content: str) -> str: + return sha256(Event.serialize(public_key, created_at, kind, tags, content)).hexdigest() + + def sign(self, private_key_hex: str) -> None: + sk = PrivateKey(bytes.fromhex(private_key_hex)) + sig = sk.schnorr_sign(bytes.fromhex(self.id), None, raw=True) + self.signature = sig.hex() + + def verify(self) -> bool: + pub_key = PublicKey(bytes.fromhex("02" + self.public_key), True) # add 02 for schnorr (bip340) + event_id = Event.compute_id(self.public_key, self.created_at, self.kind, self.tags, self.content) + return pub_key.schnorr_verify(bytes.fromhex(event_id), bytes.fromhex(self.signature), None, raw=True) + + def to_json_object(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 + } diff --git a/nostr/nostr/filter.py b/nostr/nostr/filter.py new file mode 100644 index 0000000..32b94a3 --- /dev/null +++ b/nostr/nostr/filter.py @@ -0,0 +1,81 @@ +from collections import UserList +from .event import Event + + +class Filter: + def __init__( + self, + ids: "list[str]" = None, + kinds: "list[int]" = None, + authors: "list[str]" = None, + since: int = None, + until: int = None, + tags: "dict[str, list[str]]" = None, + limit: int = None, + ) -> None: + self.IDs = ids + self.kinds = kinds + self.authors = authors + self.since = since + self.until = until + self.tags = tags + self.limit = limit + + def matches(self, event: Event) -> bool: + if self.IDs != None and event.id not in self.IDs: + return False + if self.kinds != None and event.kind not in self.kinds: + return False + if self.authors != None and event.public_key not in self.authors: + return False + if self.since != None and event.created_at < self.since: + return False + if self.until != None and event.created_at > self.until: + return False + if self.tags != None and len(event.tags) == 0: + return False + if self.tags != None: + e_tag_identifiers = [e_tag[0] for e_tag in event.tags] + for f_tag, f_tag_values in self.tags.items(): + if f_tag[1:] not in e_tag_identifiers: + return False + for e_tag in event.tags: + if e_tag[1] not in f_tag_values: + return False + + return True + + def to_json_object(self) -> dict: + res = {} + if self.IDs != None: + res["ids"] = self.IDs + if self.kinds != None: + res["kinds"] = self.kinds + if self.authors != None: + res["authors"] = self.authors + if self.since != None: + res["since"] = self.since + if self.until != None: + res["until"] = self.until + if self.tags != None: + for tag, values in self.tags.items(): + res[tag] = values + if self.limit != None: + res["limit"] = self.limit + + return res + + +class Filters(UserList): + def __init__(self, initlist: "list[Filter]" = []) -> None: + super().__init__(initlist) + self.data: "list[Filter]" + + def match(self, event: Event): + for filter in self.data: + if filter.matches(event): + return True + return False + + def to_json_array(self) -> list: + return [filter.to_json_object() for filter in self.data] diff --git a/nostr/nostr/key.py b/nostr/nostr/key.py new file mode 100644 index 0000000..9449c00 --- /dev/null +++ b/nostr/nostr/key.py @@ -0,0 +1,86 @@ +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 . import bech32 + +class PublicKey: + def __init__(self, raw_bytes: bytes) -> None: + self.raw_bytes = raw_bytes + + def bech32(self) -> str: + converted_bits = bech32.convertbits(self.raw_bytes, 8, 5) + return bech32.bech32_encode("npub", converted_bits, bech32.Encoding.BECH32) + + def hex(self) -> str: + return self.raw_bytes.hex() + + def verify_signed_message_hash(self, hash: str, sig: str) -> bool: + pk = secp256k1.PublicKey(b"\x02" + self.raw_bytes, True) + return pk.schnorr_verify(bytes.fromhex(hash), bytes.fromhex(sig), None, True) + +class PrivateKey: + def __init__(self, raw_secret: bytes=None) -> None: + if not raw_secret is None: + self.raw_secret = raw_secret + else: + self.raw_secret = secrets.token_bytes(32) + + sk = secp256k1.PrivateKey(self.raw_secret) + self.public_key = PublicKey(sk.pubkey.serialize()[1:]) + + 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: + 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) + + def encrypt_message(self, message: str, public_key_hex: str) -> str: + padder = padding.PKCS7(128).padder() + padded_data = padder.update(message.encode()) + padder.finalize() + + iv = secrets.token_bytes(16) + cipher = Cipher(algorithms.AES(self.compute_shared_secret(public_key_hex)), modes.CBC(iv)) + + encryptor = cipher.encryptor() + encrypted_message = encryptor.update(padded_data) + encryptor.finalize() + + return f"{base64.b64encode(encrypted_message).decode()}?iv={base64.b64encode(iv).decode()}" + + def decrypt_message(self, encoded_message: str, public_key_hex: str) -> str: + encoded_data = encoded_message.split('?iv=') + encoded_content, encoded_iv = encoded_data[0], encoded_data[1] + + iv = base64.b64decode(encoded_iv) + cipher = Cipher(algorithms.AES(self.compute_shared_secret(public_key_hex)), modes.CBC(iv)) + encrypted_content = base64.b64decode(encoded_content) + + decryptor = cipher.decryptor() + decrypted_message = decryptor.update(encrypted_content) + decryptor.finalize() + + unpadder = padding.PKCS7(128).unpadder() + unpadded_data = unpadder.update(decrypted_message) + unpadder.finalize() + + return unpadded_data.decode() + + def sign_message_hash(self, hash: bytes) -> str: + sk = secp256k1.PrivateKey(self.raw_secret) + sig = sk.schnorr_sign(hash, None, raw=True) + return sig.hex() + +ffi = FFI() +@ffi.callback("int (unsigned char *, const unsigned char *, const unsigned char *, void *)") +def copy_x(output, x32, y32, data): + ffi.memmove(output, x32, 32) + return 1 \ No newline at end of file diff --git a/nostr/nostr/message_pool.py b/nostr/nostr/message_pool.py new file mode 100644 index 0000000..472e31e --- /dev/null +++ b/nostr/nostr/message_pool.py @@ -0,0 +1,78 @@ +import json +from queue import Queue +from threading import Lock +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() + self.notices: Queue[NoticeMessage] = Queue() + 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) + + def get_event(self): + return self.events.get() + + def get_notice(self): + return self.notices.get() + + def get_eose_notice(self): + return self.eose_notices.get() + + def has_events(self): + return self.events.qsize() > 0 + + def has_notices(self): + return self.notices.qsize() > 0 + + def has_eose_notices(self): + return self.eose_notices.qsize() > 0 + + def _process_message(self, message: str, url: str): + message_json = json.loads(message) + message_type = message_json[0] + 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"], + ) + with self.lock: + if not event.id in self._unique_events: + self.events.put(EventMessage(event, subscription_id, url)) + self._unique_events.add(event.id) + elif message_type == RelayMessageType.NOTICE: + 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/nostr/message_type.py b/nostr/nostr/message_type.py new file mode 100644 index 0000000..3f5206b --- /dev/null +++ b/nostr/nostr/message_type.py @@ -0,0 +1,15 @@ +class ClientMessageType: + EVENT = "EVENT" + REQUEST = "REQ" + CLOSE = "CLOSE" + +class RelayMessageType: + EVENT = "EVENT" + NOTICE = "NOTICE" + END_OF_STORED_EVENTS = "EOSE" + + @staticmethod + def is_valid(type: str) -> bool: + if type == RelayMessageType.EVENT or type == RelayMessageType.NOTICE or type == RelayMessageType.END_OF_STORED_EVENTS: + return True + return False \ No newline at end of file diff --git a/nostr/nostr/relay.py b/nostr/nostr/relay.py new file mode 100644 index 0000000..ad01ff6 --- /dev/null +++ b/nostr/nostr/relay.py @@ -0,0 +1,123 @@ +import json +from threading import Lock +from websocket import WebSocketApp +from .event import Event +from .filter import Filters +from .message_pool import MessagePool +from .message_type import RelayMessageType +from .subscription import Subscription + + +class RelayPolicy: + 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} + + +class Relay: + def __init__( + 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.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, + ) + + def connect(self, ssl_options: dict = None): + self.ws.run_forever(sslopt=ssl_options) + + def close(self): + self.ws.close() + + def publish(self, message: str): + self.ws.send(message) + + def add_subscription(self, id, filters: Filters): + with self.lock: + self.subscriptions[id] = Subscription(id, filters) + + def close_subscription(self, id: str) -> None: + with self.lock: + self.subscriptions.pop(id) + + def update_subscription(self, id: str, filters: Filters) -> None: + with self.lock: + subscription = self.subscriptions[id] + subscription.filters = filters + + 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() + ], + } + + def _on_open(self, class_obj): + pass + + def _on_close(self, class_obj, status_code, message): + 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 + + def _is_valid_message(self, message: str) -> bool: + message = message.strip("\n") + if not message or message[0] != "[" or message[-1] != "]": + return False + + message_json = json.loads(message) + message_type = message_json[0] + if not RelayMessageType.is_valid(message_type): + return False + 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"], + ) + if not event.verify(): + return False + + with self.lock: + subscription = self.subscriptions[subscription_id] + + if not subscription.filters.match(event): + return False + + return True diff --git a/nostr/nostr/relay_manager.py b/nostr/nostr/relay_manager.py new file mode 100644 index 0000000..e4d177e --- /dev/null +++ b/nostr/nostr/relay_manager.py @@ -0,0 +1,43 @@ +import threading +from .filter import Filters +from .message_pool import MessagePool +from .relay import Relay, RelayPolicy + +class RelayManager: + def __init__(self) -> None: + self.relays: dict[str, Relay] = {} + self.message_pool = MessagePool() + + def add_relay(self, url: str, read: bool=True, write: bool=True, subscriptions={}): + policy = RelayPolicy(read, write) + relay = Relay(url, policy, self.message_pool, subscriptions) + self.relays[url] = relay + + def remove_relay(self, url: str): + self.relays.pop(url) + + def add_subscription(self, id: str, filters: Filters): + for relay in self.relays.values(): + relay.add_subscription(id, filters) + + def close_subscription(self, id: str): + for relay in self.relays.values(): + relay.close_subscription(id) + + def open_connections(self, ssl_options: dict=None): + for relay in self.relays.values(): + threading.Thread( + target=relay.connect, + args=(ssl_options,), + name=f"{relay.url}-thread" + ).start() + + def close_connections(self): + for relay in self.relays.values(): + relay.close() + + def publish_message(self, message: str): + for relay in self.relays.values(): + if relay.policy.should_write: + relay.publish(message) + diff --git a/nostr/nostr/subscription.py b/nostr/nostr/subscription.py new file mode 100644 index 0000000..7afba20 --- /dev/null +++ b/nostr/nostr/subscription.py @@ -0,0 +1,12 @@ +from .filter import Filters + +class Subscription: + def __init__(self, id: str, filters: Filters=None) -> None: + self.id = id + self.filters = filters + + def to_json_object(self): + return { + "id": self.id, + "filters": self.filters.to_json_array() + } diff --git a/nostr/relay.py b/nostr/relay.py new file mode 100644 index 0000000..16667eb --- /dev/null +++ b/nostr/relay.py @@ -0,0 +1,124 @@ +import json +from threading import Lock +from websocket import WebSocketApp +from .event import Event +from .filter import Filters +from .message_pool import MessagePool +from .message_type import RelayMessageType +from .subscription import Subscription + + +class RelayPolicy: + 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} + + +class Relay: + def __init__( + 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.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, + ) + + def connect(self, ssl_options: dict = None): + self.ws.run_forever(sslopt=ssl_options) + + def close(self): + self.ws.close() + + def publish(self, message: str): + self.ws.send(message) + + def add_subscription(self, id, filters: Filters): + with self.lock: + self.subscriptions[id] = Subscription(id, filters) + + def close_subscription(self, id: str) -> None: + with self.lock: + self.subscriptions.pop(id) + + def update_subscription(self, id: str, filters: Filters) -> None: + with self.lock: + subscription = self.subscriptions[id] + subscription.filters = filters + + 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() + ], + } + + def _on_open(self, class_obj): + pass + + def _on_close(self, class_obj, status_code, message): + 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): + # print(error) + pass + + def _is_valid_message(self, message: str) -> bool: + message = message.strip("\n") + if not message or message[0] != "[" or message[-1] != "]": + return False + + message_json = json.loads(message) + message_type = message_json[0] + if not RelayMessageType.is_valid(message_type): + return False + 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"], + ) + if not event.verify(): + return False + + with self.lock: + subscription = self.subscriptions[subscription_id] + + if not subscription.filters.match(event): + return False + + return True diff --git a/nostr/relay_manager.py b/nostr/relay_manager.py new file mode 100644 index 0000000..e4d177e --- /dev/null +++ b/nostr/relay_manager.py @@ -0,0 +1,43 @@ +import threading +from .filter import Filters +from .message_pool import MessagePool +from .relay import Relay, RelayPolicy + +class RelayManager: + def __init__(self) -> None: + self.relays: dict[str, Relay] = {} + self.message_pool = MessagePool() + + def add_relay(self, url: str, read: bool=True, write: bool=True, subscriptions={}): + policy = RelayPolicy(read, write) + relay = Relay(url, policy, self.message_pool, subscriptions) + self.relays[url] = relay + + def remove_relay(self, url: str): + self.relays.pop(url) + + def add_subscription(self, id: str, filters: Filters): + for relay in self.relays.values(): + relay.add_subscription(id, filters) + + def close_subscription(self, id: str): + for relay in self.relays.values(): + relay.close_subscription(id) + + def open_connections(self, ssl_options: dict=None): + for relay in self.relays.values(): + threading.Thread( + target=relay.connect, + args=(ssl_options,), + name=f"{relay.url}-thread" + ).start() + + def close_connections(self): + for relay in self.relays.values(): + relay.close() + + def publish_message(self, message: str): + for relay in self.relays.values(): + if relay.policy.should_write: + relay.publish(message) + diff --git a/nostr/subscription.py b/nostr/subscription.py new file mode 100644 index 0000000..7afba20 --- /dev/null +++ b/nostr/subscription.py @@ -0,0 +1,12 @@ +from .filter import Filters + +class Subscription: + def __init__(self, id: str, filters: Filters=None) -> None: + self.id = id + self.filters = filters + + def to_json_object(self): + return { + "id": self.id, + "filters": self.filters.to_json_array() + } diff --git a/poetry.lock b/poetry.lock index c95e088..c3cbd9f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -133,6 +133,25 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli"] +[[package]] +name = "cryptography" +version = "38.0.4" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"] +docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] +pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"] +sdist = ["setuptools-rust (>=0.11.4)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pytz"] + [[package]] name = "ecdsa" version = "0.18.0" @@ -371,6 +390,14 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +[[package]] +name = "pycryptodomex" +version = "3.16.0" +description = "Cryptographic library for Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + [[package]] name = "pydantic" version = "1.10.2" @@ -640,6 +667,19 @@ typing-extensions = {version = "*", markers = "python_version < \"3.8\""} [package.extras] standard = ["colorama (>=0.4)", "httptools (>=0.4.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.0)"] +[[package]] +name = "websocket-client" +version = "1.3.3" +description = "WebSocket client for Python with low level API options" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["Sphinx (>=3.4)", "sphinx-rtd-theme (>=0.5)"] +optional = ["python-socks", "wsaccel"] +test = ["websockets"] + [[package]] name = "win32-setctime" version = "1.1.0" @@ -666,7 +706,7 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools" [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "aa0c3cf3a023b4143939128be203cf0c519341abc7cd7ef0b200694f8b925b78" +content-hash = "d26c1683860705c1936769b5baade31986d00b3318092971ebaed265a138fb96" [metadata.files] anyio = [ @@ -843,6 +883,34 @@ coverage = [ {file = "coverage-6.5.0-pp36.pp37.pp38-none-any.whl", hash = "sha256:1431986dac3923c5945271f169f59c45b8802a114c8f548d611f2015133df77a"}, {file = "coverage-6.5.0.tar.gz", hash = "sha256:f642e90754ee3e06b0e7e51bce3379590e76b7f76b708e1a71ff043f87025c84"}, ] +cryptography = [ + {file = "cryptography-38.0.4-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:2fa36a7b2cc0998a3a4d5af26ccb6273f3df133d61da2ba13b3286261e7efb70"}, + {file = "cryptography-38.0.4-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:1f13ddda26a04c06eb57119caf27a524ccae20533729f4b1e4a69b54e07035eb"}, + {file = "cryptography-38.0.4-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:2ec2a8714dd005949d4019195d72abed84198d877112abb5a27740e217e0ea8d"}, + {file = "cryptography-38.0.4-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50a1494ed0c3f5b4d07650a68cd6ca62efe8b596ce743a5c94403e6f11bf06c1"}, + {file = "cryptography-38.0.4-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a10498349d4c8eab7357a8f9aa3463791292845b79597ad1b98a543686fb1ec8"}, + {file = "cryptography-38.0.4-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:10652dd7282de17990b88679cb82f832752c4e8237f0c714be518044269415db"}, + {file = "cryptography-38.0.4-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bfe6472507986613dc6cc00b3d492b2f7564b02b3b3682d25ca7f40fa3fd321b"}, + {file = "cryptography-38.0.4-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce127dd0a6a0811c251a6cddd014d292728484e530d80e872ad9806cfb1c5b3c"}, + {file = "cryptography-38.0.4-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:53049f3379ef05182864d13bb9686657659407148f901f3f1eee57a733fb4b00"}, + {file = "cryptography-38.0.4-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:8a4b2bdb68a447fadebfd7d24855758fe2d6fecc7fed0b78d190b1af39a8e3b0"}, + {file = "cryptography-38.0.4-cp36-abi3-win32.whl", hash = "sha256:1d7e632804a248103b60b16fb145e8df0bc60eed790ece0d12efe8cd3f3e7744"}, + {file = "cryptography-38.0.4-cp36-abi3-win_amd64.whl", hash = "sha256:8e45653fb97eb2f20b8c96f9cd2b3a0654d742b47d638cf2897afbd97f80fa6d"}, + {file = "cryptography-38.0.4-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca57eb3ddaccd1112c18fc80abe41db443cc2e9dcb1917078e02dfa010a4f353"}, + {file = "cryptography-38.0.4-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:c9e0d79ee4c56d841bd4ac6e7697c8ff3c8d6da67379057f29e66acffcd1e9a7"}, + {file = "cryptography-38.0.4-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0e70da4bdff7601b0ef48e6348339e490ebfb0cbe638e083c9c41fb49f00c8bd"}, + {file = "cryptography-38.0.4-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:998cd19189d8a747b226d24c0207fdaa1e6658a1d3f2494541cb9dfbf7dcb6d2"}, + {file = "cryptography-38.0.4-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67461b5ebca2e4c2ab991733f8ab637a7265bb582f07c7c88914b5afb88cb95b"}, + {file = "cryptography-38.0.4-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:4eb85075437f0b1fd8cd66c688469a0c4119e0ba855e3fef86691971b887caf6"}, + {file = "cryptography-38.0.4-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3178d46f363d4549b9a76264f41c6948752183b3f587666aff0555ac50fd7876"}, + {file = "cryptography-38.0.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:6391e59ebe7c62d9902c24a4d8bcbc79a68e7c4ab65863536127c8a9cd94043b"}, + {file = "cryptography-38.0.4-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:78e47e28ddc4ace41dd38c42e6feecfdadf9c3be2af389abbfeef1ff06822285"}, + {file = "cryptography-38.0.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fb481682873035600b5502f0015b664abc26466153fab5c6bc92c1ea69d478b"}, + {file = "cryptography-38.0.4-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:4367da5705922cf7070462e964f66e4ac24162e22ab0a2e9d31f1b270dd78083"}, + {file = "cryptography-38.0.4-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b4cad0cea995af760f82820ab4ca54e5471fc782f70a007f31531957f43e9dee"}, + {file = "cryptography-38.0.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:80ca53981ceeb3241998443c4964a387771588c4e4a5d92735a493af868294f9"}, + {file = "cryptography-38.0.4.tar.gz", hash = "sha256:175c1a818b87c9ac80bb7377f5520b7f31b3ef2a0004e2420319beadedb67290"}, +] ecdsa = [ {file = "ecdsa-0.18.0-py2.py3-none-any.whl", hash = "sha256:80600258e7ed2f16b9aa1d7c295bd70194109ad5a30fdee0eaeefef1d4c559dd"}, {file = "ecdsa-0.18.0.tar.gz", hash = "sha256:190348041559e21b22a1d65cee485282ca11a6f81d503fddb84d5017e9ed1e49"}, @@ -940,6 +1008,34 @@ pycparser = [ {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, ] +pycryptodomex = [ + {file = "pycryptodomex-3.16.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b3d04c00d777c36972b539fb79958790126847d84ec0129fce1efef250bfe3ce"}, + {file = "pycryptodomex-3.16.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:e5a670919076b71522c7d567a9043f66f14b202414a63c3a078b5831ae342c03"}, + {file = "pycryptodomex-3.16.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:ce338a9703f54b2305a408fc9890eb966b727ce72b69f225898bb4e9d9ed3f1f"}, + {file = "pycryptodomex-3.16.0-cp27-cp27m-manylinux2014_aarch64.whl", hash = "sha256:a1c0ae7123448ecb034c75c713189cb00ebe2d415b11682865b6c54d200d9c93"}, + {file = "pycryptodomex-3.16.0-cp27-cp27m-win32.whl", hash = "sha256:8851585ff19871e5d69e1790f4ca5f6fd1699d6b8b14413b472a4c0dbc7ea780"}, + {file = "pycryptodomex-3.16.0-cp27-cp27m-win_amd64.whl", hash = "sha256:8dd2d9e3c617d0712ed781a77efd84ea579e76c5f9b2a4bc0b684ebeddf868b2"}, + {file = "pycryptodomex-3.16.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:2ad9bb86b355b6104796567dd44c215b3dc953ef2fae5e0bdfb8516731df92cf"}, + {file = "pycryptodomex-3.16.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:e25a2f5667d91795f9417cb856f6df724ccdb0cdd5cbadb212ee9bf43946e9f8"}, + {file = "pycryptodomex-3.16.0-cp27-cp27mu-manylinux2014_aarch64.whl", hash = "sha256:b0789a8490114a2936ed77c87792cfe77582c829cb43a6d86ede0f9624ba8aa3"}, + {file = "pycryptodomex-3.16.0-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:0da835af786fdd1c9930994c78b23e88d816dc3f99aa977284a21bbc26d19735"}, + {file = "pycryptodomex-3.16.0-cp35-abi3-manylinux2014_aarch64.whl", hash = "sha256:22aed0868622d95179217c298e37ed7410025c7b29dac236d3230617d1e4ed56"}, + {file = "pycryptodomex-3.16.0-cp35-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1619087fb5b31510b0b0b058a54f001a5ffd91e6ffee220d9913064519c6a69d"}, + {file = "pycryptodomex-3.16.0-cp35-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:70288d9bfe16b2fd0d20b6c365db614428f1bcde7b20d56e74cf88ade905d9eb"}, + {file = "pycryptodomex-3.16.0-cp35-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7993d26dae4d83b8f4ce605bb0aecb8bee330bb3c95475ef06f3694403621e71"}, + {file = "pycryptodomex-3.16.0-cp35-abi3-musllinux_1_1_i686.whl", hash = "sha256:1cda60207be8c1cf0b84b9138f9e3ca29335013d2b690774a5e94678ff29659a"}, + {file = "pycryptodomex-3.16.0-cp35-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:04610536921c1ec7adba158ef570348550c9f3a40bc24be9f8da2ef7ab387981"}, + {file = "pycryptodomex-3.16.0-cp35-abi3-win32.whl", hash = "sha256:daa67f5ebb6fbf1ee9c90decaa06ca7fc88a548864e5e484d52b0920a57fe8a5"}, + {file = "pycryptodomex-3.16.0-cp35-abi3-win_amd64.whl", hash = "sha256:231dc8008cbdd1ae0e34645d4523da2dbc7a88c325f0d4a59635a86ee25b41dd"}, + {file = "pycryptodomex-3.16.0-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:4dbbe18cc232b5980c7633972ae5417d0df76fe89e7db246eefd17ef4d8e6d7a"}, + {file = "pycryptodomex-3.16.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:893f8a97d533c66cc3a56e60dd3ed40a3494ddb4aafa7e026429a08772f8a849"}, + {file = "pycryptodomex-3.16.0-pp27-pypy_73-win32.whl", hash = "sha256:6a465e4f856d2a4f2a311807030c89166529ccf7ccc65bef398de045d49144b6"}, + {file = "pycryptodomex-3.16.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ba57ac7861fd2c837cdb33daf822f2a052ff57dd769a2107807f52a36d0e8d38"}, + {file = "pycryptodomex-3.16.0-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f2b971a7b877348a27dcfd0e772a0343fb818df00b74078e91c008632284137d"}, + {file = "pycryptodomex-3.16.0-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e2453162f473c1eae4826eb10cd7bce19b5facac86d17fb5f29a570fde145abd"}, + {file = "pycryptodomex-3.16.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:0ba28aa97cdd3ff5ed1a4f2b7f5cd04e721166bd75bd2b929e2734433882b583"}, + {file = "pycryptodomex-3.16.0.tar.gz", hash = "sha256:e9ba9d8ed638733c9e95664470b71d624a6def149e2db6cc52c1aca5a6a2df1d"}, +] pydantic = [ {file = "pydantic-1.10.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bb6ad4489af1bac6955d38ebcb95079a836af31e4c4f74aba1ca05bb9f6027bd"}, {file = "pydantic-1.10.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a1f5a63a6dfe19d719b1b6e6106561869d2efaca6167f84f5ab9347887d78b98"}, @@ -1134,6 +1230,10 @@ uvicorn = [ {file = "uvicorn-0.18.3-py3-none-any.whl", hash = "sha256:0abd429ebb41e604ed8d2be6c60530de3408f250e8d2d84967d85ba9e86fe3af"}, {file = "uvicorn-0.18.3.tar.gz", hash = "sha256:9a66e7c42a2a95222f76ec24a4b754c158261c4696e683b9dadc72b590e0311b"}, ] +websocket-client = [ + {file = "websocket-client-1.3.3.tar.gz", hash = "sha256:d58c5f284d6a9bf8379dab423259fe8f85b70d5fa5d2916d5791a84594b122b1"}, + {file = "websocket_client-1.3.3-py3-none-any.whl", hash = "sha256:5d55652dc1d0b3c734f044337d929aaf83f4f9138816ec680c1aefefb4dc4877"}, +] win32-setctime = [ {file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"}, {file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"}, diff --git a/pyproject.toml b/pyproject.toml index 835dcfb..75fe525 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,9 @@ sqlalchemy-aio = "^0.17.0" python-bitcoinlib = "^0.11.2" h11 = "0.12.0" PySocks = "^1.7.1" +cryptography = "^38.0.4" +websocket-client = "1.3.3" +pycryptodomex = "^3.16.0" [tool.poetry.dev-dependencies] black = {version = "^22.8.0", allow-prereleases = true}