Techstack
Let's we walk through the building blocks of NostrMail. In addition to explaining how our implementation works, this should serve to illustrate how a similar strategy could be used for other platforms.
Dependencies¶
nostr¶
NostrMail builds on the python-nostr==0.0.2
, which may be found here.
secp256k1¶
This library handles the PGP side of NostrMail and is a dependency of python-nostr
. It is maintained by rustyrussell and may be found here.
Workflow¶
priv/pub key generation¶
If you don't already have a nostr private key, use this to generate one.
from nostr.key import PrivateKey
private_key = PrivateKey()
public_key = private_key.public_key
print(f"Private key: {private_key.bech32()}")
print(f"Public key: {public_key.bech32()}")
Copy and the above private key into .env
at the root of this repo.
NOSTR_PRIV_KEY=<priv key here>
When you run the nostrmail
container, this key will be used as the default private key if the environment variable is set.
import os
try:
priv_key = os.environ['NOSTR_PRIV_KEY']
except KeyError:
raise KeyError('Please set environment variable NOSTR_PRIV_KEY')
Connecting to proxies¶
The following code is borrowed from the python-nostr==0.0.2
docs. No attempt has been made to optimize relay connections on our part. There are open issues on python-nostr
that address this.
import json
import ssl
import time
from nostr.relay_manager import RelayManager
relay_manager = RelayManager()
relay_manager.add_relay("wss://nostr-pub.wellorder.net")
relay_manager.add_relay("wss://relay.damus.io")
relay_manager.add_relay("wss://relay.oldcity-bitcoiners.info")
relay_manager.open_connections({"cert_reqs": ssl.CERT_NONE}) # NOTE: This disables ssl certificate verification
time.sleep(1.25) # allow the connections to open
while relay_manager.message_pool.has_notices():
notice_msg = relay_manager.message_pool.get_notice()
print(notice_msg.content)
relay_manager.close_connections()
Text events¶
From NIP-01 there are three kinds of events
- 0: set_metadata: the content is set to a stringified JSON object {name:
, about: , picture: } describing the user who created the event. A relay may delete past set_metadata events once it gets a new one for the same pubkey. - 1: text_note: the content is set to the plaintext content of a note (anything the user wants to say). Markdown links ( stuff) are not plaintext.
- 2: recommend_server: the content is set to the URL (e.g., wss://somerelay.com) of a relay the event creator wants to recommend to its followers.
from nostrmail.utils import get_events
Let's view HelloJessica's nostr events.
node_hello = 'npub1k9tkawv6ga6ptz3jl30pjzh68hk5mgvl28al5zc6r0myy849wvaq38a70g'
node_hello_hex = 'b1576eb99a4774158a32fc5e190afa3ded4da19f51fbfa0b1a1bf6421ea5733a'
text_hello = get_events(node_hello_hex, 'text')
text_hello
meta_hello = get_events(node_hello_hex, 'meta')
meta_hello
Shared secret¶
First we'll create two key pairs, one for the sender and one for the receiver
from nostr.key import PrivateKey
priv_key1 = PrivateKey()
pub_key1 = priv_key1.public_key
print(f"Private key: {priv_key1.bech32()}")
print(f"Public key: {pub_key1.bech32()}")
priv_key2 = PrivateKey()
pub_key2 = priv_key2.public_key
print(f"Private key: {priv_key1.bech32()}")
print(f"Public key: {pub_key1.bech32()}")
assert priv_key1.compute_shared_secret(pub_key2.hex()) == priv_key2.compute_shared_secret(pub_key1.hex())
print('shared secret validated!')
Encryption¶
nostr-python already uses AES 256 (?) encryption. More on the encryption scheme can be found here https://github.com/jeffthibault/python-nostr/blob/37cb66ba2d3968b2d75cc8ad71c3550415ca47fe/nostr/key.py#L69
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
import secrets
iv = secrets.token_bytes(16)
cipher = Cipher(algorithms.AES(self.compute_shared_secret(public_key_hex)), modes.CBC(iv))
help(priv_key1.encrypt_message)
help(priv_key2.decrypt_message)
clear_message = 'hello there'
encrypted_msg = priv_key1.encrypt_message(clear_message, pub_key2.hex())
encrypted_msg
assert priv_key2.decrypt_message(encrypted_msg, pub_key1.hex()) == clear_message
This approach uses the key pairs alone. There is no timing information included.
Mock email flow¶
sender_priv = PrivateKey()
sender_pub = sender_priv.public_key.hex()
email_msg = """
Well, hello there!
This is a decrypted message!
"""
receiver_priv = PrivateKey()
receiver_pub = receiver_priv.public_key.hex()
sender_secret = sender_priv.compute_shared_secret(receiver_pub)
sender_secret # will match receiver secret
python-nostr/key.py
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)
The shared secret is the result of applying Eliptic Curve Diffe Hellman, so it should return a point on the elliptic curve (which is just another public key)
sender_secret # can turn into hex encoded str
encrypted_email = encrypt(email_msg, sender_secret)
encrypted_email
receiver_secret = receiver_priv.compute_shared_secret(sender_pub)
# this works because the receiver_secret matches the sender_secret (hence, shared secret)
decrypted_email = decrypt(encrypted_email, receiver_secret)
print(decrypted_email)
Try connecting to Damus¶
import json
import ssl
import time
from nostr.event import Event
from nostr.relay_manager import RelayManager
from nostr.message_type import ClientMessageType
from nostr.key import PrivateKey
relay_manager = RelayManager()
relay_manager.add_relay("wss://nostr-pub.wellorder.net")
relay_manager.add_relay("wss://relay.damus.io")
relay_manager.open_connections({"cert_reqs": ssl.CERT_NONE}) # NOTE: This disables ssl certificate verification
time.sleep(1.25) # allow the connections to open
event = Event(pub_key_hex, "Hello there")
priv_key.sign_event(event)
assert event.verify() # checks signature on event
relay_manager.publish_event(event)
time.sleep(1) # allow the messages to send
relay_manager.close_connections()
fetch event for your pub key¶
from nostrmail.utils import get_events
get_events(pub_key_hex)
from nostr.key import mine_vanity_key
Address book¶
from omegaconf import OmegaConf
import pandas as pd
import dash_bootstrap_components as dbc
from nostrmail.utils import load_contacts
load_contacts()
def update_contacts_table(url):
contacts = load_contacts()
table = dbc.Table.from_dataframe(contacts, index=True)
return table.children
create user profile¶
try:
priv_key_str = os.environ['NOSTR_PRIV_KEY']
except KeyError:
raise KeyError('Please set environment variable NOSTR_PRIV_KEY')
priv_key = PrivateKey.from_nsec(priv_key_str)
assert priv_key.bech32() == priv_key_str
from nostrmail.utils import get_events, load_current_user
from nostr.key import PrivateKey
import os
Generate Alice profile¶
import os
from nostr.event import EventKind
from nostr.key import PrivateKey
from nostr.event import Event
from nostr.relay_manager import RelayManager
import json
import ssl
alice_priv_key_str = os.environ['PRIV_KEY_ALICE']
alice_email = os.environ['EMAIL_ALICE']
alice_priv_key = PrivateKey.from_nsec(alice_priv_key_str)
assert alice_priv_key.bech32() == alice_priv_key_str
import time
from nostrmail.utils import relays, publish_profile
alice_profile = dict(display_name='Alice',
name='alice',
picture='https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTV-0rZbgnQcRbDqbk0hHPLHPyHpqLJ8xkriA&usqp=CAU',
about='my name is Alice..',
email=alice_email)
sig = publish_profile(alice_priv_key, alice_profile)
sig
Verify that profile was published
from nostrmail.utils import get_events
alice_profile_remote = get_events(alice_priv_key.public_key.hex(), 'meta')[0]
assert alice_profile_remote['email'] == alice_email
alice_profile_remote
Publish Bob's profile¶
bob_priv_key_str = os.environ['PRIV_KEY_BOB']
bob_email = os.environ['EMAIL_BOB']
bob_priv_key = PrivateKey.from_nsec(bob_priv_key_str)
assert bob_priv_key.bech32() == bob_priv_key_str
bob_priv_key.public_key.hex()
bob_profile = dict(display_name='Bob',
name='bob',
picture='https://cdnb.artstation.com/p/assets/images/images/030/065/923/large/in-house-media-bobgundisplay.jpg?1599501909',
about="I am the one they call Bob",
email=bob_email)
sig = publish_profile(bob_priv_key, bob_profile)
bob_profile_remote = get_events(bob_priv_key.public_key.hex(), 'meta')[0]
assert bob_profile_remote['email'] == bob_email
bob_profile_remote
bob_priv_key.public_key.hex()
Direct Message¶
Test delivery of the email subject via dm. The standard is defined in https://github.com/nostr-protocol/nips/blob/master/04.md
- text is encrypted with
base64-encoded, aes-256-cbc
using the x-coordinate if the shared point between sender/receiver - content includes an initialization vector
"content": "<encrypted_text>?iv=<initialization_vector>"
tags
MUST contain an entry identifying the receiver of the message in the form["p", "<pubkey, as a hex string>"]
.tags
MAY contain an entry identifying the previous message in a conversation or a message we are explicitly replying to, in the form["e", "<event_id>"]
.
from nostr.key import PrivateKey
import os
alice_priv_key_str = os.environ['PRIV_KEY_ALICE']
alice_email = os.environ['EMAIL_ADDRESS_ALICE']
alice_priv_key = PrivateKey.from_nsec(alice_priv_key_str)
assert alice_priv_key.bech32() == alice_priv_key_str
bob_priv_key_str = os.environ['PRIV_KEY_BOB']
bob_email = os.environ['EMAIL_ADDRESS_BOB']
bob_priv_key = PrivateKey.from_nsec(bob_priv_key_str)
assert bob_priv_key.bech32() == bob_priv_key_str
Confirm that we can create a valid priv key from the one provided
from nostrmail.utils import relays, publish_direct_message
# publish_direct_message(alice_priv_key, bob_priv_key.public_key.hex(), "hi ho bob!")
from nostrmail.utils import get_events
txt_events = get_events(bob_priv_key.public_key.hex(), kind='dm', returns='event')
for e in txt_events:
print(e.content, e.tags)
bob_priv_key.public_key.hex()
bob_priv_key.decrypt_message(e.content, alice_priv_key.public_key.hex())
# publish_direct_message(bob_priv_key, alice_priv_key.public_key.hex(), 'hullo, hullo!', e.id)
from nostrmail.utils import get_dms, get_convs
import pandas as pd
alice_dms = get_dms(alice_priv_key.public_key.hex())
alice_priv_key.public_key.hex()
dms = pd.DataFrame(alice_dms)
dms['conv'] = get_convs(dms)
dms
pd.DataFrame(alice_dms).set_index('time').sort_index(ascending=False)
bob_dms = get_dms(bob_priv_key.public_key.hex())
bob_dms_df = pd.DataFrame(bob_dms)
bob_dms_df['convs'] = get_convs(bob_dms_df)
bob_dms_df
bob_dms
def get_encryption_iv(msg):
"""extract the iv from an ecnrypted blob"""
return msg.split('?iv=')[-1].strip('==')
for id_, _ in pd.DataFrame(bob_dms).iterrows():
print(get_encryption_iv(_.content), alice_priv_key.decrypt_message(_.content, bob_priv_key.public_key.hex()))
alice_priv_key.decrypt_message(_.content, bob_priv_key.public_key.hex())
# from nostr.event import EncryptedDirectMessage # this isn't available as of nostr==0.0.2
# dm = EncryptedDirectMessage(
# recipient_pubkey=recipient_pubkey,
# cleartext_content="Secret message!"
# )
# private_key.sign_event(dm)
# relay_manager.publish_event(dm)
Contacts¶
There's a nip for contacts! https://github.com/nostr-protocol/nips/blob/master/02.md e.g. frank.david.erin
search email by subject¶
import imaplib
import email
import os
email_imap = os.environ['IMAP_HOST']
email_username = os.environ['EMAIL_ADDRESS']
email_password = os.environ['EMAIL_PASSWORD']
# Set up connection to IMAP server
mail = imaplib.IMAP4_SSL(email_imap)
if not email_is_logged_in(mail):
print('logging in')
mail.login(email_username, email_password)
mail.login(email_username, email_password)
email_is_logged_in(mail)
if not email_is_logged_in(mail):
print('logging in')
mail.login(email_username, email_password)
from dash import html
mail.select('Inbox')
# email_body = find_email_by_subject(mail, 'bVpH/kND9hb1p83A0saXYw')
email_body = find_email_by_subject(mail, 'r2e7cDJR6dqDgShm6w')
email_body
type(email_body)
check_if_email_logged_in(mail)
from dash import dcc
dcc.Markdown?
print(alice_priv_key.decrypt_message(email_body, bob_priv_key.public_key.hex()))
imaplib.IMAP4_SSL?
# Close the mailbox and logout from the IMAP server
mail.close()
mail.logout()
assert not email_is_logged_in(mail)
Filters¶
from nostr.filter import Filter, Filters
Filters?
Filter?
%load_ext autoreload
%autoreload 2
Block height caching¶
We'll use block height to cache profile data.
from nostrmail.utils import get_block_hash, get_block_info, get_latest_block_hash
block_hash = get_latest_block_hash()
block_hash
latest_block = get_block_info(block_hash=block_hash)
latest_block['height']