add nostr

This commit is contained in:
callebtc
2022-12-18 17:10:06 +01:00
parent 60487a14df
commit 1e38fe695f
31 changed files with 2039 additions and 1 deletions

View File

@@ -39,6 +39,9 @@ from cashu.wallet.crud import (
) )
from cashu.wallet.wallet import Wallet as Wallet 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): async def init_wallet(wallet: Wallet):
"""Performs migrations and loads proofs from db.""" """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"Socks proxy: {SOCKS_HOST}:{SOCKS_PORT}")
print(f"Mint URL: {MINT_URL}") print(f"Mint URL: {MINT_URL}")
return 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()

0
nostr/__init__.py Normal file
View File

137
nostr/bech32.py Normal file
View File

@@ -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

0
nostr/client/__init__.py Normal file
View File

86
nostr/client/aes.py Normal file
View File

@@ -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)

41
nostr/client/cbc.py Normal file
View File

@@ -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)

165
nostr/client/client.py Normal file
View File

@@ -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)

65
nostr/event.py Normal file
View File

@@ -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
}

81
nostr/filter.py Normal file
View File

@@ -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]

86
nostr/key.py Normal file
View File

@@ -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

78
nostr/message_pool.py Normal file
View File

@@ -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))

15
nostr/message_type.py Normal file
View File

@@ -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

0
nostr/nostr/__init__.py Normal file
View File

137
nostr/nostr/bech32.py Normal file
View File

@@ -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

View File

86
nostr/nostr/client/aes.py Normal file
View File

@@ -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)

41
nostr/nostr/client/cbc.py Normal file
View File

@@ -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)

View File

@@ -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)

65
nostr/nostr/event.py Normal file
View File

@@ -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
}

81
nostr/nostr/filter.py Normal file
View File

@@ -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]

86
nostr/nostr/key.py Normal file
View File

@@ -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

View File

@@ -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))

View File

@@ -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

123
nostr/nostr/relay.py Normal file
View File

@@ -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

View File

@@ -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)

View File

@@ -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()
}

124
nostr/relay.py Normal file
View File

@@ -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

43
nostr/relay_manager.py Normal file
View File

@@ -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)

12
nostr/subscription.py Normal file
View File

@@ -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()
}

102
poetry.lock generated
View File

@@ -133,6 +133,25 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1
[package.extras] [package.extras]
toml = ["tomli"] 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]] [[package]]
name = "ecdsa" name = "ecdsa"
version = "0.18.0" version = "0.18.0"
@@ -371,6 +390,14 @@ category = "main"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 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]] [[package]]
name = "pydantic" name = "pydantic"
version = "1.10.2" version = "1.10.2"
@@ -640,6 +667,19 @@ typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
[package.extras] [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)"] 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]] [[package]]
name = "win32-setctime" name = "win32-setctime"
version = "1.1.0" version = "1.1.0"
@@ -666,7 +706,7 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.7" python-versions = "^3.7"
content-hash = "aa0c3cf3a023b4143939128be203cf0c519341abc7cd7ef0b200694f8b925b78" content-hash = "d26c1683860705c1936769b5baade31986d00b3318092971ebaed265a138fb96"
[metadata.files] [metadata.files]
anyio = [ 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-pp36.pp37.pp38-none-any.whl", hash = "sha256:1431986dac3923c5945271f169f59c45b8802a114c8f548d611f2015133df77a"},
{file = "coverage-6.5.0.tar.gz", hash = "sha256:f642e90754ee3e06b0e7e51bce3379590e76b7f76b708e1a71ff043f87025c84"}, {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 = [ ecdsa = [
{file = "ecdsa-0.18.0-py2.py3-none-any.whl", hash = "sha256:80600258e7ed2f16b9aa1d7c295bd70194109ad5a30fdee0eaeefef1d4c559dd"}, {file = "ecdsa-0.18.0-py2.py3-none-any.whl", hash = "sha256:80600258e7ed2f16b9aa1d7c295bd70194109ad5a30fdee0eaeefef1d4c559dd"},
{file = "ecdsa-0.18.0.tar.gz", hash = "sha256:190348041559e21b22a1d65cee485282ca11a6f81d503fddb84d5017e9ed1e49"}, {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-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
{file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, {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 = [ 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_10_9_x86_64.whl", hash = "sha256:bb6ad4489af1bac6955d38ebcb95079a836af31e4c4f74aba1ca05bb9f6027bd"},
{file = "pydantic-1.10.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a1f5a63a6dfe19d719b1b6e6106561869d2efaca6167f84f5ab9347887d78b98"}, {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-py3-none-any.whl", hash = "sha256:0abd429ebb41e604ed8d2be6c60530de3408f250e8d2d84967d85ba9e86fe3af"},
{file = "uvicorn-0.18.3.tar.gz", hash = "sha256:9a66e7c42a2a95222f76ec24a4b754c158261c4696e683b9dadc72b590e0311b"}, {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 = [ win32-setctime = [
{file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"}, {file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"},
{file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"}, {file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"},

View File

@@ -24,6 +24,9 @@ sqlalchemy-aio = "^0.17.0"
python-bitcoinlib = "^0.11.2" python-bitcoinlib = "^0.11.2"
h11 = "0.12.0" h11 = "0.12.0"
PySocks = "^1.7.1" PySocks = "^1.7.1"
cryptography = "^38.0.4"
websocket-client = "1.3.3"
pycryptodomex = "^3.16.0"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
black = {version = "^22.8.0", allow-prereleases = true} black = {version = "^22.8.0", allow-prereleases = true}