From a146af3de4b43a3c1f4ac9ffeba5b6252dfec3a4 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Tue, 27 Dec 2022 09:04:58 -0600 Subject: [PATCH 01/11] Adds initial test framework; adds PrivateKey.from_nsec() --- .gitignore | 3 ++- README.md | 5 ++++- nostr/key.py | 11 +++++++++++ setup.py | 7 +++++++ test/README.md | 31 +++++++++++++++++++++++++++++++ test/requirements.txt | 1 + test/test_key.py | 23 +++++++++++++++++++++++ 7 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 setup.py create mode 100644 test/README.md create mode 100644 test/requirements.txt create mode 100644 test/test_key.py diff --git a/.gitignore b/.gitignore index d047acb..4ba2708 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ venv/ -nostr/__pycache__/ \ No newline at end of file +__pycache__/ +nostr.egg-info/ \ No newline at end of file diff --git a/README.md b/README.md index 12d8bdd..0c1dd17 100644 --- a/README.md +++ b/README.md @@ -114,10 +114,13 @@ pip install -r requirements.txt Note: I wrote this with Python 3.9.5. +## Test Suite +See the [Test Suite README](test/README.md) + ## Disclaimer - This library is in very early development and still a WIP. - It might have some bugs. -- I need to add tests. +- I need to add more tests. - I will try to publish this as a [PyPI](https://pypi.org/) package at some point. Please feel free to add issues, add PRs, or provide any feedback! diff --git a/nostr/key.py b/nostr/key.py index 9449c00..d5ac2f0 100644 --- a/nostr/key.py +++ b/nostr/key.py @@ -31,6 +31,13 @@ def __init__(self, raw_secret: bytes=None) -> None: 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) @@ -78,6 +85,10 @@ 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 __eq__(self, other): + return self.raw_secret == other.raw_secret + ffi = FFI() @ffi.callback("int (unsigned char *, const unsigned char *, const unsigned char *, void *)") diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..e14d103 --- /dev/null +++ b/setup.py @@ -0,0 +1,7 @@ +from setuptools import setup, find_packages + +setup( + name='nostr', + version='0.0.0', + packages=find_packages(include=['nostr']) +) \ No newline at end of file diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..a63bd98 --- /dev/null +++ b/test/README.md @@ -0,0 +1,31 @@ +# Testing python-nostr + +## Set up the test environment + +Install the test-runner dependencies: +``` +pip3 install -r test/requirements.txt +``` + +Then make the `nostr` python module visible/importable to the tests by installing the local dev dir as an editable module: +``` +# from the repo root +pip3 install -e . +``` + +## Running the test suite +Run the whole test suite: +``` +# from the repo root +pytest +``` + +Run a specific test file: +``` +pytest test/test_this_file.py +``` + +Run a specific test: +``` +pytest test/test_this_file.py::test_this_specific_test +``` \ No newline at end of file diff --git a/test/requirements.txt b/test/requirements.txt new file mode 100644 index 0000000..e7c4a16 --- /dev/null +++ b/test/requirements.txt @@ -0,0 +1 @@ +pytest>=7.2.0 \ No newline at end of file diff --git a/test/test_key.py b/test/test_key.py new file mode 100644 index 0000000..70d8522 --- /dev/null +++ b/test/test_key.py @@ -0,0 +1,23 @@ +from nostr.key import PrivateKey + + +def test_eq_true(): + """ __eq__ should return True when PrivateKeys are equal """ + pk1 = PrivateKey() + pk2 = PrivateKey(pk1.raw_secret) + assert pk1 == pk2 + + +def test_eq_false(): + """ __eq__ should return False when PrivateKeys are not equal """ + pk1 = PrivateKey() + pk2 = PrivateKey() + assert pk1.raw_secret != pk2.raw_secret + assert pk1 != pk2 + + +def test_from_nsec(): + """ PrivateKey.from_nsec should yield the source's raw_secret """ + pk1 = PrivateKey() + pk2 = PrivateKey.from_nsec(pk1.bech32()) + assert pk1.raw_secret == pk2.raw_secret From 0c14cfc9f865e5a698ae06ea61753b8dd698d66f Mon Sep 17 00:00:00 2001 From: kdmukai Date: Tue, 27 Dec 2022 13:45:51 -0600 Subject: [PATCH 02/11] Update setup.py --- setup.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index e14d103..20303c5 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,21 @@ from setuptools import setup, find_packages + +with open("README.md", "r") as f: + long_description = f.read() + setup( name='nostr', - version='0.0.0', - packages=find_packages(include=['nostr']) + version="0.0.1", + packages=find_packages(include=['nostr']), + python_requires='>3.6.0', + url='https://github.com/jeffthibault/python-nostr', + description="A Python library for making Nostr clients.", + long_description=long_description, + long_description_content_type="text/markdown", + classifiers=[ + 'Operating System :: POSIX :: Linux', + 'Operating System :: Microsoft :: Windows', + 'Operating System :: MacOS :: MacOS X', + ], ) \ No newline at end of file From f7ed74011b14ed694196c1b224f68f557aa7cebe Mon Sep 17 00:00:00 2001 From: kdmukai Date: Tue, 17 Jan 2023 07:45:15 -0600 Subject: [PATCH 03/11] Interim commit --- nostr/event.py | 73 ++++++++++++++++++++++++++++++++------------------ nostr/key.py | 16 +++++++---- 2 files changed, 58 insertions(+), 31 deletions(-) diff --git a/nostr/event.py b/nostr/event.py index 1b3aef3..aa37ab7 100644 --- a/nostr/event.py +++ b/nostr/event.py @@ -1,10 +1,11 @@ import time import json +from dataclasses import dataclass from enum import IntEnum +from typing import List from secp256k1 import PrivateKey, PublicKey from hashlib import sha256 - class EventKind(IntEnum): SET_METADATA = 0 TEXT_NOTE = 1 @@ -14,26 +15,21 @@ class EventKind(IntEnum): 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 +@dataclass +class Event: + public_key: str + content: str = "" + created_at: int = int(time.time()) + kind: int = EventKind.TEXT_NOTE + tags: List[List[str]] = None + id: str = None + signature: str = None + + + def __post_init__(self): + if self.tags is None: + self.tags = [] + @staticmethod def serialize(public_key: str, created_at: int, kind: int, tags: "list[list[str]]", content: str) -> bytes: @@ -41,19 +37,25 @@ def serialize(public_key: str, created_at: int, kind: int, tags: "list[list[str] 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 compute_id(self): + self.id = sha256(Event.serialize(self.public_key, self.created_at, self.kind, self.tags, self.content)).hexdigest() + def sign(self, private_key_hex: str) -> None: + if self.id is None: + self.compute_id() + # self.id = Event.compute_id(self.public_key, self.created_at, self.kind, self.tags, self.content) 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) + self.compute_id() + return pub_key.schnorr_verify(bytes.fromhex(self.id), bytes.fromhex(self.signature), None, raw=True) + def to_json_object(self) -> dict: return { @@ -65,3 +67,22 @@ def to_json_object(self) -> dict: "content": self.content, "sig": self.signature } + + + +@dataclass +class EncryptedDirectMessage(Event): + recipient_pubkey: str = None + cleartext_message: str = None + reference_event_id: str = None + + def __post_init__(self): + self.kind = EventKind.ENCRYPTED_DIRECT_MESSAGE + super().__post_init__() + + # Must specify the DM recipient's pubkey hex + self.tags.append(['p', self.recipient_pubkey]) + + # Optionally specify a reference event (DM) this is a reply to + if self.reference_event_id: + self.tags.append(['m', self.reference_event_id]) diff --git a/nostr/key.py b/nostr/key.py index 2521f70..ee97e8f 100644 --- a/nostr/key.py +++ b/nostr/key.py @@ -7,9 +7,9 @@ from hashlib import sha256 from nostr.delegation import Delegation +from nostr.event import EncryptedDirectMessage, Event, EventKind from . import bech32 - class PublicKey: def __init__(self, raw_bytes: bytes) -> None: self.raw_bytes = raw_bytes @@ -65,17 +65,17 @@ 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: + def encrypt_message(self, dm: EncryptedDirectMessage) -> None: padder = padding.PKCS7(128).padder() - padded_data = padder.update(message.encode()) + padder.finalize() + padded_data = padder.update(dm.cleartext_message.encode()) + padder.finalize() iv = secrets.token_bytes(16) - cipher = Cipher(algorithms.AES(self.compute_shared_secret(public_key_hex)), modes.CBC(iv)) + cipher = Cipher(algorithms.AES(self.compute_shared_secret(dm.recipient_pubkey)), 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()}" + dm.content = 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=') @@ -98,6 +98,12 @@ def sign_message_hash(self, hash: bytes) -> str: 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: + self.encrypt_message(event) + event.compute_id() + 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()) From babf1208bc92fe0d1dec9d18bdfa0e57ed73b1b1 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Tue, 17 Jan 2023 08:23:38 -0600 Subject: [PATCH 04/11] Integrate `Event` and `RelayManager` w/validity checking --- README.md | 3 +-- nostr/event.py | 27 +++++++++++++++++---------- nostr/relay_manager.py | 22 +++++++++++++++++++++- test/test_relay_manager.py | 30 ++++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 13 deletions(-) create mode 100644 test/test_relay_manager.py diff --git a/README.md b/README.md index f00def5..42f7486 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,7 @@ private_key = PrivateKey() event = Event(private_key.public_key.hex(), "Hello Nostr") event.sign(private_key.hex()) -message = json.dumps([ClientMessageType.EVENT, event.to_json_object()]) -relay_manager.publish_message(message) +relay_manager.publish_event(event) time.sleep(1) # allow the messages to send relay_manager.close_connections() diff --git a/nostr/event.py b/nostr/event.py index 1b3aef3..4f2c469 100644 --- a/nostr/event.py +++ b/nostr/event.py @@ -4,6 +4,8 @@ from secp256k1 import PrivateKey, PublicKey from hashlib import sha256 +from nostr.message_type import ClientMessageType + class EventKind(IntEnum): SET_METADATA = 0 @@ -55,13 +57,18 @@ def verify(self) -> bool: 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 - } + 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 + } + ] + ) diff --git a/nostr/relay_manager.py b/nostr/relay_manager.py index e4d177e..c0664f6 100644 --- a/nostr/relay_manager.py +++ b/nostr/relay_manager.py @@ -1,8 +1,19 @@ +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] = {} @@ -40,4 +51,13 @@ 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/test/test_relay_manager.py b/test/test_relay_manager.py new file mode 100644 index 0000000..c6e838a --- /dev/null +++ b/test/test_relay_manager.py @@ -0,0 +1,30 @@ +import pytest +from nostr.event import Event +from nostr.key import PrivateKey +from nostr.relay_manager import RelayManager, RelayException + + +def test_only_relay_valid_events(): + """ publish_event raise a RelayException if an Event fails verification """ + pk = PrivateKey() + event = Event( + public_key=pk.public_key.hex(), + content="Hello, world!", + ) + + relay_manager = RelayManager() + + # Deliberately forget to sign the Event + with pytest.raises(RelayException) as e: + relay_manager.publish_event(event) + assert "must be signed" in str(e) + + # Attempt to relay with a nonsense signature + event.signature = '0' * 32 + with pytest.raises(RelayException) as e: + relay_manager.publish_event(event) + assert "failed to verify" in str(e) + + # Properly signed Event can be relayed + event.sign(pk.hex()) + relay_manager.publish_event(event) From f926e7a2e34780e7923f1ec6295708b0044b5a14 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 22 Jan 2023 12:23:54 -0600 Subject: [PATCH 05/11] Update event.py --- nostr/event.py | 37 +++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/nostr/event.py b/nostr/event.py index ab88d30..8d8e8ec 100644 --- a/nostr/event.py +++ b/nostr/event.py @@ -23,8 +23,8 @@ class EventKind(IntEnum): @dataclass class Event: public_key: str - content: str = "" - created_at: int = int(time.time()) + content: str = None + created_at: int = None kind: int = EventKind.TEXT_NOTE tags: List[List[str]] = None id: str = None @@ -32,12 +32,22 @@ class Event: def __post_init__(self): + if self.content is not None and not isinstance(self.content, str): + raise TypeError("Argument 'content' must be of type str") + + if self.created_at is None: + self.created_at = int(time.time()) + + # Can't initialize the nested type above w/out more complex factory, so doing it here if self.tags is None: self.tags = [] + if self.id is None: + self.compute_id() + @staticmethod - def serialize(public_key: str, created_at: int, kind: int, tags: "list[list[str]]", content: str) -> bytes: + 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() @@ -47,18 +57,12 @@ def compute_id(self): self.id = sha256(Event.serialize(self.public_key, self.created_at, self.kind, self.tags, self.content)).hexdigest() - def sign(self, private_key_hex: str) -> None: - if self.id is None: - self.compute_id() - # self.id = Event.compute_id(self.public_key, self.created_at, self.kind, self.tags, self.content) - 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) + + # Always recompute id just in case something changed self.compute_id() + return pub_key.schnorr_verify(bytes.fromhex(self.id), bytes.fromhex(self.signature), None, raw=True) @@ -83,11 +87,14 @@ def to_message(self) -> str: @dataclass class EncryptedDirectMessage(Event): recipient_pubkey: str = None - cleartext_message: str = None + cleartext_content: str = None reference_event_id: str = None def __post_init__(self): + if self.content is not None: + raise Exception("Encrypted DMs cannot use the `content` field; use `cleartext_content` instead.") + self.kind = EventKind.ENCRYPTED_DIRECT_MESSAGE super().__post_init__() @@ -96,4 +103,6 @@ def __post_init__(self): # Optionally specify a reference event (DM) this is a reply to if self.reference_event_id: - self.tags.append(['m', self.reference_event_id]) + self.tags.append(['e', self.reference_event_id]) + + self.compute_id() From eb165ced9c26b265760733c08ca152a7fb8caad5 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 22 Jan 2023 12:31:12 -0600 Subject: [PATCH 06/11] Update key.py --- nostr/key.py | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/nostr/key.py b/nostr/key.py index ee97e8f..8a706e9 100644 --- a/nostr/key.py +++ b/nostr/key.py @@ -10,6 +10,7 @@ from nostr.event import EncryptedDirectMessage, Event, EventKind from . import bech32 + class PublicKey: def __init__(self, raw_bytes: bytes) -> None: self.raw_bytes = raw_bytes @@ -65,17 +66,21 @@ 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, dm: EncryptedDirectMessage) -> None: + def encrypt_message(self, message: str, public_key_hex: str) -> str: padder = padding.PKCS7(128).padder() - padded_data = padder.update(dm.cleartext_message.encode()) + padder.finalize() + padded_data = padder.update(message.encode()) + padder.finalize() iv = secrets.token_bytes(16) - cipher = Cipher(algorithms.AES(self.compute_shared_secret(dm.recipient_pubkey)), modes.CBC(iv)) + 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() - dm.content = f"{base64.b64encode(encrypted_message).decode()}?iv={base64.b64encode(iv).decode()}" + return f"{base64.b64encode(encrypted_message).decode()}?iv={base64.b64encode(iv).decode()}" + + def encrypt_dm(self, dm: EncryptedDirectMessage) -> None: + encrypted_message = self.encrypt_message(message=dm.cleartext_content, public_key_hex=dm.public_key) + dm.content = encrypted_message def decrypt_message(self, encoded_message: str, public_key_hex: str) -> str: encoded_data = encoded_message.split('?iv=') @@ -97,19 +102,32 @@ 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: self.encrypt_message(event) event.compute_id() 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 *)") From b99f930228e64e8779b233f6e6119029d07ac480 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 22 Jan 2023 12:32:58 -0600 Subject: [PATCH 07/11] Update key.py --- nostr/key.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/nostr/key.py b/nostr/key.py index 8a706e9..9222770 100644 --- a/nostr/key.py +++ b/nostr/key.py @@ -115,19 +115,7 @@ def sign_delegation(self, delegation: Delegation) -> None: 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 *)") From d6ccdb5757d735cefd6074099228ac70b78cb76a Mon Sep 17 00:00:00 2001 From: kdmukai Date: Mon, 23 Jan 2023 20:30:49 -0600 Subject: [PATCH 08/11] tests; Key.sign inserts Event.public_key --- README.md | 37 +++++++++++++++++++++-- nostr/event.py | 26 ++++++++++++---- nostr/key.py | 22 ++++---------- test/test_event.py | 54 +++++++++++++++++++++++++-------- test/test_key.py | 74 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 176 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index ca176a2..d079ed1 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ public_key = private_key.public_key print(f"Private key: {private_key.bech32()}") print(f"Public key: {public_key.bech32()}") ``` + **Connect to relays** ```python import json @@ -30,6 +31,7 @@ while relay_manager.message_pool.has_notices(): relay_manager.close_connections() ``` + **Publish to relays** ```python import json @@ -48,7 +50,7 @@ time.sleep(1.25) # allow the connections to open private_key = PrivateKey() -event = Event(private_key.public_key.hex(), "Hello Nostr") +event = Event("Hello Nostr") private_key.sign_event(event) relay_manager.publish_event(event) @@ -56,6 +58,38 @@ time.sleep(1) # allow the messages to send relay_manager.close_connections() ``` + +**Reply to a note** +```python +from nostr.event import Event + +reply = Event( + content="Hey, that's a great point!", +) + +# create 'e' tag reference to the note you're replying to +reply.add_event_ref(original_note_id) + +# create 'p' tag reference to the pubkey you're replying to +reply.add_pubkey_ref(original_note_author_pubkey) + +private_key.sign_event(reply) +relay_manager.publish_event(reply) +``` + +**Send a DM** +```python +from nostr.event import EncryptedDirectMessage + +dm = EncryptedDirectMessage( + recipient_pubkey=recipient_pubkey, + cleartext_content="Secret message!" +) +private_key.sign_event(dm) +relay_manager.publish_event(dm) +``` + + **Receive events from relays** ```python import json @@ -112,7 +146,6 @@ delegation = Delegation( identity_pk.sign_delegation(delegation) event = Event( - delegatee_pk.public_key.hex(), "Hello, NIP-26!", tags=[delegation.get_tag()], ) diff --git a/nostr/event.py b/nostr/event.py index 8493349..a8e2af2 100644 --- a/nostr/event.py +++ b/nostr/event.py @@ -21,7 +21,7 @@ class EventKind(IntEnum): @dataclass class Event: - public_key: str + public_key: str = None content: str = None created_at: int = None kind: int = EventKind.TEXT_NOTE @@ -32,6 +32,7 @@ class Event: 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: @@ -56,6 +57,18 @@ def compute_id(self): self.id = sha256(Event.serialize(self.public_key, self.created_at, self.kind, self.tags, self.content)).hexdigest() + def add_pubkey_ref(self, pubkey:str): + """ Adds a reference to a pubkey as a 'p' tag """ + self.tags.append(['p', pubkey]) + self.compute_id() + + + 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]) + self.compute_id() + + def verify(self) -> bool: pub_key = PublicKey(bytes.fromhex("02" + self.public_key), True) # add 02 for schnorr (bip340) @@ -94,14 +107,15 @@ def __post_init__(self): if self.content is not None: raise Exception("Encrypted DMs cannot use the `content` field; use `cleartext_content` instead.") + 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 hex in a tag - self.tags.append(['p', self.recipient_pubkey]) + # 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: - self.tags.append(['e', self.reference_event_id]) - - self.compute_id() + self.add_event_ref(self.reference_event_id) diff --git a/nostr/key.py b/nostr/key.py index b62cf30..985375a 100644 --- a/nostr/key.py +++ b/nostr/key.py @@ -79,7 +79,7 @@ def encrypt_message(self, message: str, public_key_hex: str) -> str: return f"{base64.b64encode(encrypted_message).decode()}?iv={base64.b64encode(iv).decode()}" def encrypt_dm(self, dm: EncryptedDirectMessage) -> None: - encrypted_message = self.encrypt_message(message=dm.cleartext_content, public_key_hex=dm.public_key) + encrypted_message = self.encrypt_message(message=dm.cleartext_content, public_key_hex=dm.recipient_pubkey) dm.content = encrypted_message def decrypt_message(self, encoded_message: str, public_key_hex: str) -> str: @@ -104,8 +104,10 @@ def sign_message_hash(self, hash: bytes) -> str: return sig.hex() def sign_event(self, event: Event) -> None: - if event.kind == EventKind.ENCRYPTED_DIRECT_MESSAGE: - self.encrypt_message(event) + if event.kind == EventKind.ENCRYPTED_DIRECT_MESSAGE and event.content is None: + self.encrypt_dm(event) + if not event.public_key: + event.public_key = self.public_key.hex() event.compute_id() event.signature = self.sign_message_hash(bytes.fromhex(event.id)) @@ -115,20 +117,6 @@ def sign_delegation(self, delegation: Delegation) -> None: 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 *)") diff --git a/test/test_event.py b/test/test_event.py index b6e2088..5d77fff 100644 --- a/test/test_event.py +++ b/test/test_event.py @@ -1,14 +1,44 @@ -from nostr.event import Event -from nostr.key import PrivateKey +import pytest import time +from nostr.event import Event, EncryptedDirectMessage +from nostr.key import PrivateKey + + + +class TestEvent: + def test_event_default_time(self): + """ + ensure created_at default value reflects the time at Event object instantiation + see: https://github.com/jeffthibault/python-nostr/issues/23 + """ + public_key = PrivateKey().public_key.hex() + event1 = Event(public_key=public_key, content='test event') + time.sleep(1.5) + event2 = Event(public_key=public_key, content='test event') + assert event1.created_at < event2.created_at + + + +class TestEncryptedDirectMessage: + def setup_class(self): + self.sender_pk = PrivateKey() + self.sender_pubkey = self.sender_pk.public_key.hex() + self.recipient_pk = PrivateKey() + self.recipient_pubkey = self.recipient_pk.public_key.hex() + + + def test_content_field_not_allowed(self): + """ Should not let users instantiate a new DM with `content` field data """ + with pytest.raises(Exception) as e: + EncryptedDirectMessage(recipient_pubkey=self.recipient_pubkey, content="My message!") + + assert "cannot use" in str(e) + -def test_event_default_time(): - """ - ensure created_at default value reflects the time at Event object instantiation - see: https://github.com/jeffthibault/python-nostr/issues/23 - """ - public_key = PrivateKey().public_key.hex() - event1 = Event(public_key=public_key, content='test event') - time.sleep(1.5) - event2 = Event(public_key=public_key, content='test event') - assert event1.created_at < event2.created_at + def test_recipient_p_tag(self): + """ Should generate recipient 'p' tag """ + dm = EncryptedDirectMessage( + recipient_pubkey=self.recipient_pubkey, + cleartext_content="Secret message!" + ) + assert ['p', self.recipient_pubkey] in dm.tags diff --git a/test/test_key.py b/test/test_key.py index 70d8522..37c868f 100644 --- a/test/test_key.py +++ b/test/test_key.py @@ -1,3 +1,4 @@ +from nostr.event import Event, EncryptedDirectMessage from nostr.key import PrivateKey @@ -21,3 +22,76 @@ def test_from_nsec(): pk1 = PrivateKey() pk2 = PrivateKey.from_nsec(pk1.bech32()) assert pk1.raw_secret == pk2.raw_secret + + + +class TestEvent: + def setup_class(self): + self.sender_pk = PrivateKey() + self.sender_pubkey = self.sender_pk.public_key.hex() + + + def test_sign_is_valid(self): + """ sign should create a signature that can be verified against Event.id """ + event = Event(content="Hello, world!") + self.sender_pk.sign_event(event) + + assert event.verify() + + + def test_sign_adds_pubkey(self): + """ sign should add the sender's pubkey if not already specified """ + event = Event(content="Hello, world!") + assert event.public_key is None + + self.sender_pk.sign_event(event) + + assert event.public_key == self.sender_pubkey + + + +class TestEncryptedDirectMessage: + def setup_class(self): + self.sender_pk = PrivateKey() + self.sender_pubkey = self.sender_pk.public_key.hex() + self.recipient_pk = PrivateKey() + self.recipient_pubkey = self.recipient_pk.public_key.hex() + + + def test_encrypt_dm(self): + """ Should encrypt a DM and populate its `content` field with ciphertext that either party can decrypt """ + message = "My secret message!" + + dm = EncryptedDirectMessage( + recipient_pubkey=self.recipient_pubkey, + cleartext_content=message, + ) + + # DM's content field should be initially blank + assert dm.content is None + self.sender_pk.encrypt_dm(dm) + + # After encrypting, the content field should now be populated + assert dm.content is not None + + # Sender should be able to decrypt + decrypted_message = self.sender_pk.decrypt_message(encoded_message=dm.content, public_key_hex=self.recipient_pubkey) + assert decrypted_message == message + + # Recipient should be able to decrypt by referencing the sender's pubkey + decrypted_message = self.recipient_pk.decrypt_message(encoded_message=dm.content, public_key_hex=self.sender_pubkey) + assert decrypted_message == message + + + def test_sign_encrypts_dm(self): + """ `sign` should encrypt a DM that hasn't been encrypted yet """ + dm = EncryptedDirectMessage( + recipient_pubkey=self.recipient_pubkey, + cleartext_content="Some DM message", + ) + + assert dm.content is None + + self.sender_pk.sign_event(dm) + + assert dm.content is not None From 72fee7e28da0713745532c85d87d0e6ef6beb1bb Mon Sep 17 00:00:00 2001 From: kdmukai Date: Mon, 23 Jan 2023 20:39:47 -0600 Subject: [PATCH 09/11] Update test_key.py --- test/test_key.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/test_key.py b/test/test_key.py index 37c868f..f665b99 100644 --- a/test/test_key.py +++ b/test/test_key.py @@ -31,21 +31,23 @@ def setup_class(self): self.sender_pubkey = self.sender_pk.public_key.hex() - def test_sign_is_valid(self): + def test_sign_event_is_valid(self): """ sign should create a signature that can be verified against Event.id """ event = Event(content="Hello, world!") self.sender_pk.sign_event(event) - assert event.verify() - def test_sign_adds_pubkey(self): + def test_sign_event_adds_pubkey(self): """ sign should add the sender's pubkey if not already specified """ event = Event(content="Hello, world!") + + # The event's public_key hasn't been specified yet assert event.public_key is None self.sender_pk.sign_event(event) + # PrivateKey.sign() should have populated public_key assert event.public_key == self.sender_pubkey From 6287207149e1e7d9420bd82fcaa091e87ea12242 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Mon, 23 Jan 2023 21:03:56 -0600 Subject: [PATCH 10/11] Update test_event.py --- test/test_event.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/test/test_event.py b/test/test_event.py index 5d77fff..c446691 100644 --- a/test/test_event.py +++ b/test/test_event.py @@ -11,11 +11,24 @@ def test_event_default_time(self): ensure created_at default value reflects the time at Event object instantiation see: https://github.com/jeffthibault/python-nostr/issues/23 """ - public_key = PrivateKey().public_key.hex() - event1 = Event(public_key=public_key, content='test event') + event1 = Event(content='test event') time.sleep(1.5) - event2 = Event(public_key=public_key, content='test event') + event2 = Event(content='test event') assert event1.created_at < event2.created_at + + + def test_add_event_ref(self): + some_event_id = "some_event_id" + event = Event(content="Adding an 'e' tag") + event.add_event_ref(some_event_id) + assert ['e', some_event_id] in event.tags + + + def test_add_pubkey_ref(self): + some_pubkey = "some_pubkey" + event = Event(content="Adding a 'p' tag") + event.add_pubkey_ref(some_pubkey) + assert ['p', some_pubkey] in event.tags From 256c91704a1ddb3bdf50b2d569993c5739e3f860 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Thu, 2 Feb 2023 10:12:00 -0600 Subject: [PATCH 11/11] Improvements based on review * Simpler Event instantiation w/content string w/out kwarg. * `tags` attr default value via factory. * `id` changed to a compute-on-the-fly property; eliminates all the redundant `compute_id()` calls. * Simpler DM instantiation (same as `Event`); simply copies the `content` field over to `cleartext_content` and no longer throws Exception. * Asking for a DM's `id` raises an Exception if its message hasn't been encrypted yet. * Adds/updates relevant Event tests. * Restores key.mine_vanity_key. --- nostr/event.py | 46 +++++++++++++++++++------------------ nostr/key.py | 21 +++++++++++++---- test/test_event.py | 56 +++++++++++++++++++++++++++++++++++++--------- 3 files changed, 87 insertions(+), 36 deletions(-) diff --git a/nostr/event.py b/nostr/event.py index a8e2af2..11f56c6 100644 --- a/nostr/event.py +++ b/nostr/event.py @@ -1,6 +1,6 @@ import time import json -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import IntEnum from typing import List from secp256k1 import PrivateKey, PublicKey @@ -9,6 +9,7 @@ from nostr.message_type import ClientMessageType + class EventKind(IntEnum): SET_METADATA = 0 TEXT_NOTE = 1 @@ -21,12 +22,11 @@ class EventKind(IntEnum): @dataclass class Event: - public_key: str = None content: str = None + public_key: str = None created_at: int = None kind: int = EventKind.TEXT_NOTE - tags: List[List[str]] = None - id: str = None + tags: List[List[str]] = field(default_factory=list) # Dataclasses require special handling when the default value is a mutable type signature: str = None @@ -38,13 +38,6 @@ def __post_init__(self): if self.created_at is None: self.created_at = int(time.time()) - # Can't initialize the nested type above w/out more complex factory, so doing it here - if self.tags is None: - self.tags = [] - - if self.id is None: - self.compute_id() - @staticmethod def serialize(public_key: str, created_at: int, kind: int, tags: List[List[str]], content: str) -> bytes: @@ -53,28 +46,29 @@ def serialize(public_key: str, created_at: int, kind: int, tags: List[List[str]] return data_str.encode() - def compute_id(self): - self.id = sha256(Event.serialize(self.public_key, self.created_at, self.kind, self.tags, self.content)).hexdigest() + @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]) - self.compute_id() 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]) - self.compute_id() def verify(self) -> bool: - pub_key = PublicKey(bytes.fromhex("02" + self.public_key), True) # add 02 for schnorr (bip340) - - # Always recompute id just in case something changed - self.compute_id() - + 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) @@ -105,7 +99,8 @@ class EncryptedDirectMessage(Event): def __post_init__(self): if self.content is not None: - raise Exception("Encrypted DMs cannot use the `content` field; use `cleartext_content` instead.") + self.cleartext_content = self.content + self.content = None if self.recipient_pubkey is None: raise Exception("Must specify a recipient_pubkey.") @@ -117,5 +112,12 @@ def __post_init__(self): self.add_pubkey_ref(self.recipient_pubkey) # Optionally specify a reference event (DM) this is a reply to - if self.reference_event_id: + 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/nostr/key.py b/nostr/key.py index 985375a..350c72d 100644 --- a/nostr/key.py +++ b/nostr/key.py @@ -79,8 +79,7 @@ def encrypt_message(self, message: str, public_key_hex: str) -> str: return f"{base64.b64encode(encrypted_message).decode()}?iv={base64.b64encode(iv).decode()}" def encrypt_dm(self, dm: EncryptedDirectMessage) -> None: - encrypted_message = self.encrypt_message(message=dm.cleartext_content, public_key_hex=dm.recipient_pubkey) - dm.content = encrypted_message + 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=') @@ -106,9 +105,8 @@ def sign_message_hash(self, hash: bytes) -> str: def sign_event(self, event: Event) -> None: if event.kind == EventKind.ENCRYPTED_DIRECT_MESSAGE and event.content is None: self.encrypt_dm(event) - if not event.public_key: + if event.public_key is None: event.public_key = self.public_key.hex() - event.compute_id() event.signature = self.sign_message_hash(bytes.fromhex(event.id)) def sign_delegation(self, delegation: Delegation) -> None: @@ -118,6 +116,21 @@ 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): diff --git a/test/test_event.py b/test/test_event.py index c446691..b968a98 100644 --- a/test/test_event.py +++ b/test/test_event.py @@ -17,7 +17,27 @@ def test_event_default_time(self): assert event1.created_at < event2.created_at + def test_content_only_instantiation(self): + """ should be able to create an Event by only specifying content without kwarg """ + event = Event("Hello, world!") + assert event.content is not None + + + def test_event_id_recomputes(self): + """ should recompute the Event.id to reflect the current Event attrs """ + event = Event(content="some event") + + # id should be computed on the fly + event_id = event.id + + event.created_at += 10 + + # Recomputed id should now be different + assert event.id != event_id + + def test_add_event_ref(self): + """ should add an 'e' tag for each event_ref added """ some_event_id = "some_event_id" event = Event(content="Adding an 'e' tag") event.add_event_ref(some_event_id) @@ -25,6 +45,7 @@ def test_add_event_ref(self): def test_add_pubkey_ref(self): + """ should add a 'p' tag for each pubkey_ref added """ some_pubkey = "some_pubkey" event = Event(content="Adding a 'p' tag") event.add_pubkey_ref(some_pubkey) @@ -40,18 +61,33 @@ def setup_class(self): self.recipient_pubkey = self.recipient_pk.public_key.hex() - def test_content_field_not_allowed(self): - """ Should not let users instantiate a new DM with `content` field data """ - with pytest.raises(Exception) as e: - EncryptedDirectMessage(recipient_pubkey=self.recipient_pubkey, content="My message!") - - assert "cannot use" in str(e) + def test_content_field_moved_to_cleartext_content(self): + """ Should transfer `content` field data to `cleartext_content` """ + dm = EncryptedDirectMessage(content="My message!", recipient_pubkey=self.recipient_pubkey) + assert dm.content is None + assert dm.cleartext_content is not None + + + def test_nokwarg_content_allowed(self): + """ Should allow creating a new DM w/no `content` nor `cleartext_content` kwarg """ + dm = EncryptedDirectMessage("My message!", recipient_pubkey=self.recipient_pubkey) + assert dm.cleartext_content is not None def test_recipient_p_tag(self): """ Should generate recipient 'p' tag """ - dm = EncryptedDirectMessage( - recipient_pubkey=self.recipient_pubkey, - cleartext_content="Secret message!" - ) + dm = EncryptedDirectMessage(cleartext_content="Secret message!", recipient_pubkey=self.recipient_pubkey) assert ['p', self.recipient_pubkey] in dm.tags + + + def test_unencrypted_dm_has_undefined_id(self): + """ Should raise Exception if `id` is requested before DM is encrypted """ + dm = EncryptedDirectMessage(cleartext_content="My message!", recipient_pubkey=self.recipient_pubkey) + + with pytest.raises(Exception) as e: + dm.id + assert "undefined" in str(e) + + # But once we encrypt it, we can request its id + self.sender_pk.encrypt_dm(dm) + assert dm.id is not None