= PrivateKey()
private_key print(private_key)
PrivateKey(npub1j8l27...zthqnwmn00)
python-nostr
This repository relies heavily on python-nostr for underlying functionality. This notebook will walk you through the key characteristics of the nostr protocol and also make some changes to the classes from
python-nostr
that will be used in the basic client implementation.
This notebook will attempt to describe nostr via python code. There are many resources dedicated to describing nostr in words. See:
In short, nostr is a communication protocol that provides a relatively simple framework for events and metadata to be published to servers (relays) after being cryptographically signed using a private key. In orther words, the data published to the relays is visible by anyone, but provably created by the person who holds the private key associated with the event. A technical description of how the events are created can be found here and a current list of the different event types here.
Because of the simplicity and openness of the nostr protocol a long list of tools and clients with different purposes have already been built on the nostr protocol as can be seen on awesome-nostr. There will continue to be more as new people are exposed to the protocol and the accepted event types continue to expand.
Fundamentally, nostr relays and clients are built on a relatively simple set of tools - most of which can be described nicely by looking at the modules of python-nostr
, which is what we’ll do here. Specifically, covered topics in this notebook can be accessed quickly via the sidebar and include:
PrivateKey
and PublicKey
Event
Relay
, RelayManager
, and the MessagePool
Subscription
In nostr, the private key is the root of “account” ownership. The public key is derived from a private key using the same eliptic curve cryptography that is used for bitcoint private and public key pairs. nostr events are then signed using Schnorr Signatures, which prove “ownership” of the event. We will cover events in more detail later.
In this documentation you will see two versions of the PublicKey
and PrivateKey
classes, one version loaded directly from the python-nostr.key
module as key.Class
and one with added hex and bech32 functionality as denoted without key.
in front.
First, let’s look at the PrivateKey
classes
By default the class is instantiated with raw bytes as shown below
PrivateKey (raw_secret:bytes=None)
Initialize self. See help(type(self)) for accurate signature.
Instantiation with no argument gives a new random private key
more commonly you will likely instantiate the private key from a hex string
PrivateKey.from_hex (hex:str)
or after the introduction of bech32 entities in NIP-19 (private keys denoted as nsec...
) like so
PrivateKey.from_nsec (nsec:str)
Load a PrivateKey from its bech32/nsec form
The PublicKey
class from python-nostr
has very similar characteristics to the private key class, but with the key difference that the public key can be generated from the private key and the reverse is not possible by design. Below are the docs for instantiating a PublicKey
object in python-nostr
PublicKey (raw_bytes:bytes)
Initialize self. See help(type(self)) for accurate signature.
Unlike the PrivateKey
class, the PublicKey
class requires an input to be created. A random public key would mean that no one knows the associated private key and so that is not allowed. Instead, we will get our public key from the private key we already generated
PublicKey(npub1j8l27...zthqnwmn00)
The public can can also be retrieved from a public hex
PublicKey.from_hex (hex:str)
or an npub bech32 encoded public key
PublicKey.from_npub (npub:str)
Load a PublicKey from its bech32/npub form
Events are the key substance through which user communication happens over Nostr. Right now, the most common event type is a text_note
, but there are many other event kinds that can be published to a nostr relay. Events are generally made up of the following:
id
- the hash of the event content - if the content of the a created event changes on a relay the old id
would no longer match the hash of the contentpubkey
- the public key of the author of the eventcreated_at
- a time stamp given to the event at time of creationkind
- integer labeling the kind of eventtags
- a list of arrays with tags such as e
for related events or p
for related people (like mentions)content
- the content of an event - the format of the content will vary depending on the kind of event. For a text_note
(kind 1) this would be a stringsignature
- a signature generated from the private key associated with the public key and the event ID field - a valid signature ensures that anyone can check that a specific event was in fact signed by the private key of the author and that the content has not changed from the time the event was signed.Note: the above all applies specifically to event creation. An event can be republished across many relays by any party as long as the event data has not changed. Some relays have limits on the age of an event, but where the event lives and who put it there is completely irrelevant to the validity of an event. This has many interesting implications, some of which will be explored elsewhere in this module.
In python-nostr
we create events like so:
Event (public_key:str, content:str, created_at:int=1674361640, kind:int=<EventKind.TEXT_NOTE: 1>, tags:list[list[str]]=[], id:str=None, signature:str=None)
Initialize self. See help(type(self)) for accurate signature.
As example, we can create a new event
event = Event(
public_key=public_key.hex(),
content='this is a test',
kind=EventKind.TEXT_NOTE,
created_at=int(time.time(),)
)
pprint.pprint(event.to_json_object())
{'content': 'this is a test',
'created_at': 1674322515,
'id': '589ad714c2a36929abd4a817a2177c594d31e5c373948af9c22890f5590c17f6',
'kind': <EventKind.TEXT_NOTE: 1>,
'pubkey': '91feaf1b03c1dc0ed61163f77b4cf4d1821cce392983d4c68d82207a773f12ee',
'sig': None,
'tags': []}
The id hash is automatically generated from the event content, but the signature is blank because the event has not been signed with a private key. Below we can test signing an event with our real private key and also a random private key. We see that one event verifies that the public key, signature, and event id all match, while the event signed with the wrong private key does not verify.
event.sign(PrivateKey().hex())
assert not event.verify()
event.sign(private_key.hex())
assert event.verify()
pprint.pprint(event.to_json_object())
{'content': 'this is a test',
'created_at': 1674322515,
'id': '589ad714c2a36929abd4a817a2177c594d31e5c373948af9c22890f5590c17f6',
'kind': <EventKind.TEXT_NOTE: 1>,
'pubkey': '91feaf1b03c1dc0ed61163f77b4cf4d1821cce392983d4c68d82207a773f12ee',
'sig': '23bfb21c8f9594ca068e52d7d752af05041fc710dcf3cf9c63064890c1687b8271cd0d36c874fed13dc5caa849b5cfae17c9969d182b0f36612f6585eb7e3ef3',
'tags': []}
Additionally, if we change the content and don’t recompute both the ID
and the signature
then the event will not verify.
As long as we do both of these things the event verifies event with changed data.
python-nostr
also supports other event types. The basic client module in this library can help build the content and tags of other event types in the correct format.
Though this package is primarily focused on the tools needed to run a nostr client - we will talk a bit about only enough to understand how they serve the client. More detailed information on relays can always be found in the NIPs section of the nostr github repository. For testing in this package we are going to use a python package called nostr-relay
, which is installed alongside this package. It will run a local server that we can connect to instead of pinging public servers over and over with tests.
Relays act as a simple data server for clients to interact with. Clients can publish events a relay and make requests (in the form of subscriptions) to get events back from a relay.
Connections between clients and relays are made over a ‘websocket’ (typically handled by websockets in Python). A single websocket between a client and a relay can handle all data requests and typically should remain open as long as the client is live. Repeated connection and disconnections of websockets can put extra, unneeded strain on relays.
The message types that can be exchanged between a client and a relay are outlined here:
EVENT
- A nostr event being published to a relayREQ
- a client requesting a subscription to events from a relayCLOSE
- a client closing a subscriptionEVENT
- an incoming event from a relayNOTICE
- human-readable text with an explanation of any issuesEOSE
- a notice that all past stored messages have been transferred and that all future messages are newly publishedOK
- a True/False response if an EVENT was published successfullypython-nostr
uses a MessagePool
class to Queue
the incoming stream of messages from the individial Relay
websockets that are all running on different threads.
This package implements slightly modified versions of the Relay
, RelayManager
, and MessagePool
classes found in python-nostr
the basic usage is nearly identical, with a few added features that make building a the Client
class easier. Added features that aren’t yet included in python-nostr
:
Connection
context manager that is used to open connections on a Relay
or RelayManager
object using a with
statementRelay
open_connections
method on a single relay to mirror the thread-based connection opening used in the RelayManager
class.is_connected
property to see if the websocket is openRelayManager
__iter__
method that allows the client to access the Relay
objects by iterating directly on the RelayManager
object instead of RelayManager.relays.values()
The documentation below shows how a Relay is instantiated using python-nostr
. The relay policy is defined by the RelayPolicy
class
Relay (url:str, policy:nostr.relay.RelayPolicy, message_pool:nostr.message_pool.MessagePool, subscriptions:dict[str,nostr.subscription.Subscription]={})
Initialize self. See help(type(self)) for accurate signature.
Remember that in this package we will use a Relay
class that inherits the python-nostr
relay class. The same will go for the relay manager and the message pool.
Below we test using the context manager from the Relay
class in this package to connect and disconnect to a relay.
url='ws://127.0.0.1:6969'
a_relay = Relay(
url=url,
policy=RelayPolicy(),
message_pool=MessagePool()
)
assert not a_relay.is_connected
with a_relay.connection(ssl_options={'cert_reqs': ssl.CERT_NONE}):
time.sleep(.5)
print(f'Is the relay connected? {a_relay.is_connected}')
assert not a_relay.is_connected
time.sleep(.5)
INFO: ('127.0.0.1', 51211) - "WebSocket /" [accepted]
2023-01-21 09:35:27,716 - nostr_relay.web - INFO - Accepted 127.0.0.1-2a7e from Origin: http://127.0.0.1:6969
INFO: connection open
2023-01-21 09:35:27,725 - websocket - WARNING - websocket connected
Is the relay connected? True
2023-01-21 09:35:28,212 - nostr_relay.web - INFO - Done 127.0.0.1-2a7e. sent: 0 bytes. duration: 0sec
INFO: connection closed
and make sure that when it errors out it properly reports the error and proceeds to safely close the connection to the relay
def error_in_context():
with a_relay.connection(ssl_options={'cert_reqs': ssl.CERT_NONE}):
time.sleep(.5)
raise Exception()
test_fail(error_in_context)
assert not a_relay.is_connected
INFO: ('127.0.0.1', 51216) - "WebSocket /" [accepted]
2023-01-21 09:35:29,804 - nostr_relay.web - INFO - Accepted 127.0.0.1-b577 from Origin: http://127.0.0.1:6969
INFO: connection open
2023-01-21 09:35:29,817 - websocket - WARNING - websocket connected
2023-01-21 09:35:30,303 - nostr_relay.web - INFO - Done 127.0.0.1-b577. sent: 0 bytes. duration: 0sec
INFO: connection closed
Let’s try to publish an event to a our local relay - typically you would do this through the relay manager as to publish content across multiple relays, but for testing purposes we will only use one.
Relay.publish (message:str)
We need to get our message to the relay in the right format as specified by NIP-01
message = [message_type.ClientMessageType.EVENT, event.to_json_object()]
message = json.dumps(message)
print(message)
["EVENT", {"id": "290b51301f55bf2ad51dba58e935cd6fbef8b9780c5e557a706c9403ae12b345", "pubkey": "91feaf1b03c1dc0ed61163f77b4cf4d1821cce392983d4c68d82207a773f12ee", "created_at": 1674322516.899612, "kind": 1, "tags": [], "content": "this is a test", "sig": "0c205776f1470aa1391d2556032b0a666fc9f88c5dfb1ddf968851a1186da8ea5ba3ca05c7202d68ee1b6b010323fd221220e13f16342cf319e7b347b12ed591"}]
And now we can publish it.
with a_relay.connection(ssl_options={'cert_reqs': ssl.CERT_NONE}):
time.sleep(.5)
a_relay.publish(message)
INFO: ('127.0.0.1', 51222) - "WebSocket /" [accepted]
2023-01-21 09:35:35,170 - nostr_relay.web - INFO - Accepted 127.0.0.1-3be4 from Origin: http://127.0.0.1:6969
INFO: connection open
2023-01-21 09:35:35,176 - websocket - WARNING - websocket connected
INFO: connection closed
2023-01-21 09:35:35,679 - nostr_relay.web - INFO - 127.0.0.1-3be4 added 290b51301f55bf2ad51dba58e935cd6fbef8b9780c5e557a706c9403ae12b345 from 91feaf1b03c1dc0ed61163f77b4cf4d1821cce392983d4c68d82207a773f12ee
2023-01-21 09:35:35,685 - nostr_relay.web - INFO - Done 127.0.0.1-3be4. sent: 0 bytes. duration: 1sec
We will try to read this later.
Generally, most interactions will happen through the RelayManager
class. The relay manager allows easy interaction across multiple relays. Key methods are documented below
RelayManager.add_relay (url:str, read:bool=True, write:bool=True, subscriptions=None)
RelayManager.remove_relay (url:str)
RelayManager.publish_message (message:str)
You can also access connection statuses of the relays with RelayManager.connection_statuses
. Below we use that property to check connections after making a RelayManager
object, adding a Relay
and connecting with the context manager.
manager = RelayManager()
urls=[url]
for url in urls:
manager.add_relay(url=url)
with manager.connection():
print('opening')
print(manager.connection_statuses)
assert all(manager.connection_statuses.values())
print('closing')
print(manager.connection_statuses)
assert not any(manager.connection_statuses.values())
INFO: ('127.0.0.1', 51225) - "WebSocket /" [accepted]
2023-01-21 09:35:40,202 - nostr_relay.web - INFO - Accepted 127.0.0.1-ef13 from Origin: http://127.0.0.1:6969
INFO: connection open
2023-01-21 09:35:40,211 - websocket - WARNING - websocket connected
opening
{'ws://127.0.0.1:6969': True}
closing
{'ws://127.0.0.1:6969': False}
2023-01-21 09:35:42,207 - nostr_relay.web - INFO - Done 127.0.0.1-ef13. sent: 0 bytes. duration: 2sec
Relay
objects that don’t connect will be remove automatically and throw a warning.
manager = RelayManager()
urls=['ws://this-relay-doesnt-exist.com']
for u in urls:
manager.add_relay(url=u)
with manager.connection():
pass
assert len(manager.relays) == 0
INFO: connection closed
2023-01-21 09:35:42,308 - websocket - ERROR - [Errno 8] nodename nor servname provided, or not known - goodbye
/var/folders/3x/w083pw853418fqm2blj59w480000gn/T/ipykernel_10817/1720938854.py:40: UserWarning: ws://this-relay-doesnt-exist.com is not connected... removing relay.
warnings.warn(
Because we have multiple open websockets, the connections are threaded in Python. The MessagePool
class acts as a shared Queue where different types of messages from the relay can all be accessed. The only significant change from the python-nostr MessagePool
to this package is allowing the client to chose whether it should only keep the first event or whether it should keep multiples of event messages across all relays. Keeping all responses from all relays will allow the client to check how well distributed the user content is across the relays.
MessagePool (first_response_only:bool=True)
Initialize self. See help(type(self)) for accurate signature.
The key events to access the MessagePool
in python-nostr
are listed below. We will add more functionality at the client level to modify how these events are handled upon receipt.
MessagePool.get_event ()
MessagePool.get_notice ()
MessagePool.get_eose_notice ()
In order to populate the message pool we need subscriptions.
The nostr protocol requires clients to make requests (as subscriptions) to the relays in order to get events sent back (in our case, into the MessagePool
). The REQ
(request or subscription) query is also clearly outlined in NIP-01. A request must be labeled with a subscription_id
and a series of filters that the relay will use to determine which events it should return. Current options for a single filter are as follow:
ids
- a list of event IDsauthors
- a list of authors (pubkeys), which could be something like a contact listkinds
- a list of kinds, such as text_notes
(1) or set_metadata
(0)tags
- a list of tags, like e
event or p
people tagssince
- a time stamp that defines the earliest created_at
that should be sentuntil
- a time stamp that defines the latest created_at
that should be sentlimit
- a limit of events to return upon initial request to keep the client from being overwhelmed if the query is wideA list of filters is provided in a REQ
message and the following happens:
End Of Stored Events
notice with the subscription_id
until
clause of a subscription is metsubscription_id
and it overwrites the initial behaviorCLOSE
message to close the subscription_id
Filter (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)
Initialize self. See help(type(self)) for accurate signature.
Subscription (id:str, filters:nostr.filter.Filters=None)
Initialize self. See help(type(self)) for accurate signature.
We can create a subscription to request the event we created earlier
filters = filter.Filters([filter.Filter(ids=[event.id])])
subscription_id = str(uuid.uuid4())
request = [message_type.ClientMessageType.REQUEST, subscription_id]
request.extend(filters.to_json_array())
message = json.dumps(request)
print(message)
["REQ", "10000", {"ids": ["290b51301f55bf2ad51dba58e935cd6fbef8b9780c5e557a706c9403ae12b345"]}]
Now we can get that from the relay we published it to
manager = RelayManager()
manager.add_relay(url)
with manager.connection(ssl_options={'cert_reqs': ssl.CERT_NONE}):
time.sleep(.5)
manager.add_subscription(subscription_id, filters)
manager.publish_message(message)
time.sleep(.5)
while manager.message_pool.has_events():
event_msg = manager.message_pool.get_event()
print(f'event received from {event_msg.url} '
f'with subscription id {event_msg.url}\n\t'
f'{event_msg.event.content}')
INFO: ('127.0.0.1', 51244) - "WebSocket /" [accepted]
2023-01-21 09:36:08,568 - nostr_relay.web - INFO - Accepted 127.0.0.1-eb74 from Origin: http://127.0.0.1:6969
INFO: connection open
2023-01-21 09:36:08,577 - websocket - WARNING - websocket connected
2023-01-21 09:36:11,075 - nostr_relay.db - INFO - 127.0.0.1-eb74/10000 query – events:1 duration:1ms
event received from ws://127.0.0.1:6969 with subscription id ws://127.0.0.1:6969
this is a test
2023-01-21 09:36:11,577 - nostr_relay.web - INFO - Done 127.0.0.1-eb74. sent: 400 bytes. duration: 3sec
INFO: connection closed
We will use the Client
class in this package to make subscriptions and publishing events even easier.