From d2689bf65bebbe067ed1670923c5674b6c2914bb Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 15 Oct 2023 15:41:21 +0200 Subject: [PATCH] adjust nostr imports --- .gitmodules | 3 - README.md | 2 +- cashu/nostr | 1 - cashu/nostr/.gitignore | 6 ++ cashu/nostr/__init__.py | 0 cashu/nostr/bech32.py | 137 ++++++++++++++++++++++++ cashu/nostr/client/__init__.py | 0 cashu/nostr/client/cbc.py | 41 ++++++++ cashu/nostr/client/client.py | 151 +++++++++++++++++++++++++++ cashu/nostr/delegation.py | 32 ++++++ cashu/nostr/event.py | 126 +++++++++++++++++++++++ cashu/nostr/filter.py | 134 ++++++++++++++++++++++++ cashu/nostr/key.py | 153 +++++++++++++++++++++++++++ cashu/nostr/message_pool.py | 77 ++++++++++++++ cashu/nostr/message_type.py | 15 +++ cashu/nostr/pow.py | 54 ++++++++++ cashu/nostr/relay.py | 183 +++++++++++++++++++++++++++++++++ cashu/nostr/relay_manager.py | 69 +++++++++++++ cashu/nostr/subscription.py | 12 +++ cashu/wallet/api/router.py | 2 +- cashu/wallet/cli/cli.py | 2 +- cashu/wallet/nostr.py | 6 +- 22 files changed, 1196 insertions(+), 10 deletions(-) delete mode 100644 .gitmodules delete mode 160000 cashu/nostr create mode 100644 cashu/nostr/.gitignore create mode 100644 cashu/nostr/__init__.py create mode 100644 cashu/nostr/bech32.py create mode 100644 cashu/nostr/client/__init__.py create mode 100644 cashu/nostr/client/cbc.py create mode 100644 cashu/nostr/client/client.py create mode 100644 cashu/nostr/delegation.py create mode 100644 cashu/nostr/event.py create mode 100644 cashu/nostr/filter.py create mode 100644 cashu/nostr/key.py create mode 100644 cashu/nostr/message_pool.py create mode 100644 cashu/nostr/message_type.py create mode 100644 cashu/nostr/pow.py create mode 100644 cashu/nostr/relay.py create mode 100644 cashu/nostr/relay_manager.py create mode 100644 cashu/nostr/subscription.py diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index eaa87cc4..00000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "cashu/nostr"] - path = cashu/nostr - url = https://github.com/callebtc/python-nostr/ diff --git a/README.md b/README.md index 56501c18..4ae71951 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ source ~/.bashrc #### Poetry: Install Cashu ```bash # install cashu -git clone https://github.com/callebtc/cashu.git --recurse-submodules +git clone https://github.com/callebtc/cashu.git cd cashu pyenv local 3.10.4 poetry install diff --git a/cashu/nostr b/cashu/nostr deleted file mode 160000 index 880dd118..00000000 --- a/cashu/nostr +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 880dd1183a03d0aa42e66ce418b782b06d8ce519 diff --git a/cashu/nostr/.gitignore b/cashu/nostr/.gitignore new file mode 100644 index 00000000..161ec36a --- /dev/null +++ b/cashu/nostr/.gitignore @@ -0,0 +1,6 @@ +venv/ +__pycache__/ +nostr.egg-info/ +dist/ +nostr/_version.py +.DS_Store diff --git a/cashu/nostr/__init__.py b/cashu/nostr/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cashu/nostr/bech32.py b/cashu/nostr/bech32.py new file mode 100644 index 00000000..b068de77 --- /dev/null +++ b/cashu/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/cashu/nostr/client/__init__.py b/cashu/nostr/client/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cashu/nostr/client/cbc.py b/cashu/nostr/client/cbc.py new file mode 100644 index 00000000..a41dbc07 --- /dev/null +++ b/cashu/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/cashu/nostr/client/client.py b/cashu/nostr/client/client.py new file mode 100644 index 00000000..26d523fa --- /dev/null +++ b/cashu/nostr/client/client.py @@ -0,0 +1,151 @@ +from typing import Optional, List +import ssl +import time +import json +import os +import base64 + +from ..event import Event +from ..relay_manager import RelayManager +from ..message_type import ClientMessageType +from ..key import PrivateKey, PublicKey + +from ..filter import Filter, Filters +from ..event import Event, EventKind, EncryptedDirectMessage +from ..relay_manager import RelayManager +from ..message_type import ClientMessageType + +from . import cbc + + +class NostrClient: + relays = [ + "wss://nostr-pub.wellorder.net", + "wss://relay.damus.io", + "wss://nostr.zebedee.cloud", + "wss://relay.snort.social", + "wss://nostr.fmt.wiz.biz", + "wss://nos.lol", + "wss://nostr.oxtr.dev", + "wss://relay.current.fyi", + "wss://relay.snort.social", + ] # ["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, private_key: str = "", relays: List[str] = [], connect=True): + self.generate_keys(private_key) + + if len(relays): + self.relays = relays + if connect: + self.connect() + + def connect(self): + 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, private_key: Optional[str] = None): + if private_key and private_key.startswith("nsec"): + self.private_key = PrivateKey.from_nsec(private_key) + elif private_key: + self.private_key = PrivateKey(bytes.fromhex(private_key)) + else: + self.private_key = PrivateKey() # generate random key + self.public_key = self.private_key.public_key + + def post(self, message: str): + event = Event(message, self.public_key.hex(), kind=EventKind.TEXT_NOTE) + self.private_key.sign_event(event) + event_json = event.to_message() + # print("Publishing message:") + # print(event_json) + self.relay_manager.publish_message(event_json) + + def get_post( + self, + sender_publickey: Optional[PublicKey] = None, + callback_func=None, + filter_kwargs={}, + ): + authors = [sender_publickey.hex()] if sender_publickey else [] + filter = Filter( + authors=authors, + kinds=[EventKind.TEXT_NOTE], + **filter_kwargs, + ) + filters = Filters([filter]) + 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) + 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 callback_func: + callback_func(event_msg.event) + time.sleep(0.1) + + def dm(self, message: str, to_pubkey: PublicKey): + dm = EncryptedDirectMessage( + recipient_pubkey=to_pubkey.hex(), cleartext_content=message + ) + self.private_key.sign_event(dm) + self.relay_manager.publish_event(dm) + + def get_dm(self, sender_publickey: PublicKey, callback_func=None, filter_kwargs={}): + filters = Filters( + [ + Filter( + kinds=[EventKind.ENCRYPTED_DIRECT_MESSAGE], + pubkey_refs=[sender_publickey.hex()], + **filter_kwargs, + ) + ] + ) + 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) + 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 + ) + 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")) + dec_text = aes.decrypt(iv, enc_text) + if callback_func: + callback_func(event_msg.event, dec_text) + except: + pass + break + time.sleep(0.1) + + def subscribe(self, callback_func=None): + while True: + while self.relay_manager.message_pool.has_events(): + event_msg = self.relay_manager.message_pool.get_event() + if callback_func: + callback_func(event_msg.event) + time.sleep(0.1) diff --git a/cashu/nostr/delegation.py b/cashu/nostr/delegation.py new file mode 100644 index 00000000..94801f5c --- /dev/null +++ b/cashu/nostr/delegation.py @@ -0,0 +1,32 @@ +import time +from dataclasses import dataclass + + +@dataclass +class Delegation: + delegator_pubkey: str + delegatee_pubkey: str + event_kind: int + duration_secs: int = 30*24*60 # default to 30 days + signature: str = None # set in PrivateKey.sign_delegation + + @property + def expires(self) -> int: + return int(time.time()) + self.duration_secs + + @property + def conditions(self) -> str: + return f"kind={self.event_kind}&created_at<{self.expires}" + + @property + def delegation_token(self) -> str: + return f"nostr:delegation:{self.delegatee_pubkey}:{self.conditions}" + + def get_tag(self) -> list[str]: + """ Called by Event """ + return [ + "delegation", + self.delegator_pubkey, + self.conditions, + self.signature, + ] diff --git a/cashu/nostr/event.py b/cashu/nostr/event.py new file mode 100644 index 00000000..b903e0ee --- /dev/null +++ b/cashu/nostr/event.py @@ -0,0 +1,126 @@ +import time +import json +from dataclasses import dataclass, field +from enum import IntEnum +from typing import List +from secp256k1 import PublicKey +from hashlib import sha256 + +from .message_type import ClientMessageType + + +class EventKind(IntEnum): + SET_METADATA = 0 + TEXT_NOTE = 1 + RECOMMEND_RELAY = 2 + CONTACTS = 3 + ENCRYPTED_DIRECT_MESSAGE = 4 + DELETE = 5 + + +@dataclass +class Event: + content: str = None + public_key: str = None + created_at: int = None + kind: int = EventKind.TEXT_NOTE + tags: List[List[str]] = field( + default_factory=list + ) # Dataclasses require special handling when the default value is a mutable type + signature: str = None + + def __post_init__(self): + if self.content is not None and not isinstance(self.content, str): + # DMs initialize content to None but all other kinds should pass in a str + raise TypeError("Argument 'content' must be of type str") + + if self.created_at is None: + self.created_at = int(time.time()) + + @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 + ): + return sha256( + Event.serialize(public_key, created_at, kind, tags, content) + ).hexdigest() + + @property + def id(self) -> str: + # Always recompute the id to reflect the up-to-date state of the Event + return Event.compute_id( + self.public_key, self.created_at, self.kind, self.tags, self.content + ) + + def add_pubkey_ref(self, pubkey: str): + """Adds a reference to a pubkey as a 'p' tag""" + self.tags.append(["p", pubkey]) + + def add_event_ref(self, event_id: str): + """Adds a reference to an event_id as an 'e' tag""" + self.tags.append(["e", event_id]) + + def verify(self) -> bool: + pub_key = PublicKey( + bytes.fromhex("02" + self.public_key), True + ) # add 02 for schnorr (bip340) + return pub_key.schnorr_verify( + bytes.fromhex(self.id), bytes.fromhex(self.signature), None, raw=True + ) + + def to_message(self) -> str: + return json.dumps( + [ + ClientMessageType.EVENT, + { + "id": self.id, + "pubkey": self.public_key, + "created_at": self.created_at, + "kind": self.kind, + "tags": self.tags, + "content": self.content, + "sig": self.signature, + }, + ] + ) + + +@dataclass +class EncryptedDirectMessage(Event): + recipient_pubkey: str = None + cleartext_content: str = None + reference_event_id: str = None + + def __post_init__(self): + if self.content is not None: + self.cleartext_content = self.content + self.content = None + + if self.recipient_pubkey is None: + raise Exception("Must specify a recipient_pubkey.") + + self.kind = EventKind.ENCRYPTED_DIRECT_MESSAGE + super().__post_init__() + + # Must specify the DM recipient's pubkey in a 'p' tag + self.add_pubkey_ref(self.recipient_pubkey) + + # Optionally specify a reference event (DM) this is a reply to + if self.reference_event_id is not None: + self.add_event_ref(self.reference_event_id) + + @property + def id(self) -> str: + if self.content is None: + raise Exception( + "EncryptedDirectMessage `id` is undefined until its message is encrypted and stored in the `content` field" + ) + return super().id diff --git a/cashu/nostr/filter.py b/cashu/nostr/filter.py new file mode 100644 index 00000000..f119079c --- /dev/null +++ b/cashu/nostr/filter.py @@ -0,0 +1,134 @@ +from collections import UserList +from typing import List + +from .event import Event, EventKind + + +class Filter: + """ + NIP-01 filtering. + + Explicitly supports "#e" and "#p" tag filters via `event_refs` and `pubkey_refs`. + + Arbitrary NIP-12 single-letter tag filters are also supported via `add_arbitrary_tag`. + If a particular single-letter tag gains prominence, explicit support should be + added. For example: + # arbitrary tag + filter.add_arbitrary_tag('t', [hashtags]) + + # promoted to explicit support + Filter(hashtag_refs=[hashtags]) + """ + + def __init__( + self, + event_ids: List[str] = None, + kinds: List[EventKind] = None, + authors: List[str] = None, + since: int = None, + until: int = None, + event_refs: List[ + str + ] = None, # the "#e" attr; list of event ids referenced in an "e" tag + pubkey_refs: List[ + str + ] = None, # The "#p" attr; list of pubkeys referenced in a "p" tag + limit: int = None, + ) -> None: + self.event_ids = event_ids + self.kinds = kinds + self.authors = authors + self.since = since + self.until = until + self.event_refs = event_refs + self.pubkey_refs = pubkey_refs + self.limit = limit + + self.tags = {} + if self.event_refs: + self.add_arbitrary_tag("e", self.event_refs) + if self.pubkey_refs: + self.add_arbitrary_tag("p", self.pubkey_refs) + + def add_arbitrary_tag(self, tag: str, values: list): + """ + Filter on any arbitrary tag with explicit handling for NIP-01 and NIP-12 + single-letter tags. + """ + # NIP-01 'e' and 'p' tags and any NIP-12 single-letter tags must be prefixed with "#" + tag_key = tag if len(tag) > 1 else f"#{tag}" + self.tags[tag_key] = values + + def matches(self, event: Event) -> bool: + if self.event_ids is not None and event.id not in self.event_ids: + return False + if self.kinds is not None and event.kind not in self.kinds: + return False + if self.authors is not None and event.public_key not in self.authors: + return False + if self.since is not None and event.created_at < self.since: + return False + if self.until is not None and event.created_at > self.until: + return False + if (self.event_refs is not None or self.pubkey_refs is not None) and len( + event.tags + ) == 0: + return False + + if self.tags: + e_tag_identifiers = set([e_tag[0] for e_tag in event.tags]) + for f_tag, f_tag_values in self.tags.items(): + # Omit any NIP-01 or NIP-12 "#" chars on single-letter tags + f_tag = f_tag.replace("#", "") + + if f_tag not in e_tag_identifiers: + # Event is missing a tag type that we're looking for + return False + + # Multiple values within f_tag_values are treated as OR search; an Event + # needs to match only one. + # Note: an Event could have multiple entries of the same tag type + # (e.g. a reply to multiple people) so we have to check all of them. + match_found = False + for e_tag in event.tags: + if e_tag[0] == f_tag and e_tag[1] in f_tag_values: + match_found = True + break + if not match_found: + return False + + return True + + def to_json_object(self) -> dict: + res = {} + if self.event_ids is not None: + res["ids"] = self.event_ids + if self.kinds is not None: + res["kinds"] = self.kinds + if self.authors is not None: + res["authors"] = self.authors + if self.since is not None: + res["since"] = self.since + if self.until is not None: + res["until"] = self.until + if self.limit is not None: + res["limit"] = self.limit + if self.tags: + res.update(self.tags) + + 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/cashu/nostr/key.py b/cashu/nostr/key.py new file mode 100644 index 00000000..6988964a --- /dev/null +++ b/cashu/nostr/key.py @@ -0,0 +1,153 @@ +import secrets +import base64 +import secp256k1 +from cffi import FFI +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives import padding +from hashlib import sha256 + +from .delegation import Delegation +from .event import EncryptedDirectMessage, Event, EventKind +from . import bech32 + + +class PublicKey: + def __init__(self, raw_bytes: bytes = None) -> 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) + + @classmethod + def from_npub(cls, npub: str): + """Load a PublicKey from its bech32/npub form""" + hrp, data, spec = bech32.bech32_decode(npub) + raw_public_key = bech32.convertbits(data, 5, 8)[:-1] + return cls(bytes(raw_public_key)) + + +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:]) + + @classmethod + def from_nsec(cls, nsec: str): + """Load a PrivateKey from its bech32/nsec form""" + hrp, data, spec = bech32.bech32_decode(nsec) + raw_secret = bech32.convertbits(data, 5, 8)[:-1] + return cls(bytes(raw_secret)) + + 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 encrypt_dm(self, dm: EncryptedDirectMessage) -> None: + dm.content = self.encrypt_message( + message=dm.cleartext_content, public_key_hex=dm.recipient_pubkey + ) + + 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() + + def sign_event(self, event: Event) -> None: + if event.kind == EventKind.ENCRYPTED_DIRECT_MESSAGE and event.content is None: + self.encrypt_dm(event) + if event.public_key is None: + event.public_key = self.public_key.hex() + event.signature = self.sign_message_hash(bytes.fromhex(event.id)) + + def sign_delegation(self, delegation: Delegation) -> None: + delegation.signature = self.sign_message_hash( + sha256(delegation.delegation_token.encode()).digest() + ) + + def __eq__(self, other): + return self.raw_secret == other.raw_secret + + +def mine_vanity_key(prefix: str = None, suffix: str = None) -> PrivateKey: + if prefix is None and suffix is None: + raise ValueError("Expected at least one of 'prefix' or 'suffix' arguments") + + while True: + sk = PrivateKey() + if ( + prefix is not None + and not sk.public_key.bech32()[5 : 5 + len(prefix)] == prefix + ): + continue + if suffix is not None and not sk.public_key.bech32()[-len(suffix) :] == suffix: + continue + break + + return sk + + +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 diff --git a/cashu/nostr/message_pool.py b/cashu/nostr/message_pool.py new file mode 100644 index 00000000..d364cf20 --- /dev/null +++ b/cashu/nostr/message_pool.py @@ -0,0 +1,77 @@ +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["content"], + e["pubkey"], + e["created_at"], + e["kind"], + e["tags"], + e["sig"], + ) + with self.lock: + if not event.id in self._unique_events: + self.events.put(EventMessage(event, subscription_id, url)) + 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/cashu/nostr/message_type.py b/cashu/nostr/message_type.py new file mode 100644 index 00000000..3f5206bd --- /dev/null +++ b/cashu/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/cashu/nostr/pow.py b/cashu/nostr/pow.py new file mode 100644 index 00000000..e0062884 --- /dev/null +++ b/cashu/nostr/pow.py @@ -0,0 +1,54 @@ +import time +from .event import Event +from .key import PrivateKey + +def zero_bits(b: int) -> int: + n = 0 + + if b == 0: + return 8 + + while b >> 1: + b = b >> 1 + n += 1 + + return 7 - n + +def count_leading_zero_bits(hex_str: str) -> int: + total = 0 + for i in range(0, len(hex_str) - 2, 2): + bits = zero_bits(int(hex_str[i:i+2], 16)) + total += bits + + if bits != 8: + break + + return total + +def mine_event(content: str, difficulty: int, public_key: str, kind: int, tags: list=[]) -> Event: + all_tags = [["nonce", "1", str(difficulty)]] + all_tags.extend(tags) + + created_at = int(time.time()) + event_id = Event.compute_id(public_key, created_at, kind, all_tags, content) + num_leading_zero_bits = count_leading_zero_bits(event_id) + + attempts = 1 + while num_leading_zero_bits < difficulty: + attempts += 1 + all_tags[0][1] = str(attempts) + created_at = int(time.time()) + event_id = Event.compute_id(public_key, created_at, kind, all_tags, content) + num_leading_zero_bits = count_leading_zero_bits(event_id) + + return Event(public_key, content, created_at, kind, all_tags, event_id) + +def mine_key(difficulty: int) -> PrivateKey: + sk = PrivateKey() + num_leading_zero_bits = count_leading_zero_bits(sk.public_key.hex()) + + while num_leading_zero_bits < difficulty: + sk = PrivateKey() + num_leading_zero_bits = count_leading_zero_bits(sk.public_key.hex()) + + return sk diff --git a/cashu/nostr/relay.py b/cashu/nostr/relay.py new file mode 100644 index 00000000..ee78baac --- /dev/null +++ b/cashu/nostr/relay.py @@ -0,0 +1,183 @@ +import json +import time +from queue import Queue +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.connected: bool = False + self.reconnect: bool = True + self.error_counter: int = 0 + self.error_threshold: int = 0 + self.num_received_events: int = 0 + self.num_sent_events: int = 0 + self.num_subscriptions: int = 0 + self.ssl_options: dict = {} + self.proxy: dict = {} + self.lock = Lock() + self.queue = Queue() + self.ws = WebSocketApp( + url, + on_open=self._on_open, + on_message=self._on_message, + on_error=self._on_error, + on_close=self._on_close, + on_ping=self._on_ping, + on_pong=self._on_pong, + ) + + def connect(self, ssl_options: dict = None, proxy: dict = None): + self.ssl_options = ssl_options + self.proxy = proxy + if not self.connected: + self.ws.run_forever( + sslopt=ssl_options, + http_proxy_host=None if proxy is None else proxy.get("host"), + http_proxy_port=None if proxy is None else proxy.get("port"), + proxy_type=None if proxy is None else proxy.get("type"), + ping_interval=5, + ) + + def close(self): + self.ws.close() + + def check_reconnect(self): + try: + self.close() + except: + pass + self.connected = False + if self.reconnect: + time.sleep(1) + self.connect(self.ssl_options, self.proxy) + + @property + def ping(self): + ping_ms = int((self.ws.last_pong_tm - self.ws.last_ping_tm) * 1000) + return ping_ms if self.connected and ping_ms > 0 else 0 + + def publish(self, message: str): + self.queue.put(message) + + def queue_worker(self): + while True: + if self.connected: + message = self.queue.get() + self.num_sent_events += 1 + self.ws.send(message) + else: + time.sleep(0.1) + + 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): + self.connected = True + pass + + def _on_close(self, class_obj, status_code, message): + self.connected = False + pass + + def _on_message(self, class_obj, message: str): + if self._is_valid_message(message): + self.num_received_events += 1 + self.message_pool.add_message(message, self.url) + + def _on_error(self, class_obj, error): + self.connected = False + self.error_counter += 1 + if self.error_threshold and self.error_counter > self.error_threshold: + pass + else: + self.check_reconnect() + + def _on_ping(self, class_obj, message): + return + + def _on_pong(self, class_obj, message): + return + + 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["content"], + e["pubkey"], + e["created_at"], + e["kind"], + e["tags"], + e["sig"], + ) + if not event.verify(): + return False + + with self.lock: + subscription = self.subscriptions[subscription_id] + + if subscription.filters and not subscription.filters.match(event): + return False + + return True diff --git a/cashu/nostr/relay_manager.py b/cashu/nostr/relay_manager.py new file mode 100644 index 00000000..5b92d8d4 --- /dev/null +++ b/cashu/nostr/relay_manager.py @@ -0,0 +1,69 @@ +import json +import threading + +from .event import Event +from .filter import Filters +from .message_pool import MessagePool +from .message_type import ClientMessageType +from .relay import Relay, RelayPolicy + + +class RelayException(Exception): + pass + + +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, proxy: dict = None): + for relay in self.relays.values(): + threading.Thread( + target=relay.connect, + args=(ssl_options, proxy), + name=f"{relay.url}-thread", + daemon=True, + ).start() + + threading.Thread( + target=relay.queue_worker, name=f"{relay.url}-queue", daemon=True + ).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) + + def publish_event(self, event: Event): + """Verifies that the Event is publishable before submitting it to relays""" + if event.signature is None: + raise RelayException(f"Could not publish {event.id}: must be signed") + + if not event.verify(): + raise RelayException( + f"Could not publish {event.id}: failed to verify signature {event.signature}" + ) + self.publish_message(event.to_message()) diff --git a/cashu/nostr/subscription.py b/cashu/nostr/subscription.py new file mode 100644 index 00000000..7afba204 --- /dev/null +++ b/cashu/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/cashu/wallet/api/router.py b/cashu/wallet/api/router.py index 48116abd..f7fd0d4c 100644 --- a/cashu/wallet/api/router.py +++ b/cashu/wallet/api/router.py @@ -11,7 +11,7 @@ from ...core.base import TokenV3 from ...core.helpers import sum_proofs from ...core.settings import settings -from ...nostr.nostr.client.client import NostrClient +from ...nostr.client.client import NostrClient from ...tor.tor import TorProxy from ...wallet.crud import get_lightning_invoices, get_reserved_proofs from ...wallet.helpers import ( diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index f43ba870..39174dd0 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -17,7 +17,7 @@ from ...core.base import TokenV3 from ...core.helpers import sum_proofs from ...core.settings import settings -from ...nostr.nostr.client.client import NostrClient +from ...nostr.client.client import NostrClient from ...tor.tor import TorProxy from ...wallet.crud import ( get_lightning_invoices, diff --git a/cashu/wallet/nostr.py b/cashu/wallet/nostr.py index 69ac6534..acfb8d81 100644 --- a/cashu/wallet/nostr.py +++ b/cashu/wallet/nostr.py @@ -7,9 +7,9 @@ from requests.exceptions import ConnectionError from ..core.settings import settings -from ..nostr.nostr.client.client import NostrClient -from ..nostr.nostr.event import Event -from ..nostr.nostr.key import PublicKey +from ..nostr.client.client import NostrClient +from ..nostr.event import Event +from ..nostr.key import PublicKey from .crud import get_nostr_last_check_timestamp, set_nostr_last_check_timestamp from .helpers import deserialize_token_from_string, receive from .wallet import Wallet