Source code for nostr

"""Nostr protocol implementation.

https://github.com/nostr-protocol/nostr
https://github.com/nostr-protocol/nips/blob/master/01.md
https://github.com/nostr-protocol/nips#list

Nostr Object key ids are NIP-21 nostr:... URIs.
https://nips.nostr.com/21
"""
from datetime import timedelta, timezone
import logging
from urllib.parse import urlparse, urlunparse

from flask import request
from google.cloud import ndb
from google.cloud.ndb.query import OR
from granary import as1
import granary.nostr
from granary.nostr import (
    bech32_prefix_for,
    id_and_sign,
    id_to_uri,
    is_bech32,
    KIND_ARTICLE,
    KIND_CONTACTS,
    KIND_DELETE,
    KIND_GENERIC_REPOST,
    KIND_NOTE,
    KIND_PROFILE,
    KIND_REACTION,
    KIND_RELAYS,
    KIND_REPOST,
    ID_RE,
    nip05_to_npub,
    uri_to_id,
)
from requests import RequestException
import secp256k1
from websockets.exceptions import ConnectionClosedOK
from webutil import flask_util, util
from webutil.flask_util import get_required_param
from webutil.models import StringIdModel
from webutil.util import add, json_dumps, json_loads
from werkzeug.exceptions import NotFound

import common
from common import error
import domains
import filters
from domains import DOMAINS
from flask_app import app
import ids
import memcache
from models import Follower, Object, PROTOCOLS, Target, User
from protocol import Protocol, STORE_AS1_TYPES
import web

logger = logging.getLogger(__name__)


[docs] class NostrRelay(StringIdModel): """The last ``created_at`` we've seen from a given relay. Key id is full relay URI, eg ``wss://nos.lol``. Used in ``nostr_hub``. https://nips.nostr.com/1#from-client-to-relay-sending-events-and-creating-subscriptions """ since = ndb.IntegerProperty() '' created = ndb.DateTimeProperty(auto_now_add=True, tzinfo=timezone.utc) '' updated = ndb.DateTimeProperty(auto_now=True, tzinfo=timezone.utc) ''
[docs] class Nostr(User, Protocol): """Nostr class. Key id is hex pubkey with nostr: prefix. """ ABBREV = 'nostr' PHRASE = 'Nostr' LOGO_EMOJI = 'ð“…¦' # ostrich-ish bird LOGO_HTML = '<img src="/static/nostr_logo.png">' CONTENT_TYPE = 'application/json' HAS_COPIES = True DEFAULT_TARGET = 'wss://nos.lol/' REQUIRES_AVATAR = True REQUIRES_NAME = True DEFAULT_ENABLED_PROTOCOLS = ('web',) SUPPORTED_AS1_TYPES = frozenset( tuple(as1.ACTOR_TYPES) + tuple(as1.POST_TYPES) # note that update is supported for actors and articles, but not notes # https://github.com/nostr-protocol/nips/issues/646 # we override check_supported() below to check for this + tuple(as1.CRUD_VERBS) + ('follow', 'like', 'share', 'stop-following') ) # only applies to incoming events, in nostr_hub. # TODO: add KIND_CONTACTS once we're ready to handle it. # https://github.com/snarfed/bridgy-fed/issues/2203 SUPPORTED_KINDS = frozenset(( KIND_ARTICLE, KIND_DELETE, KIND_GENERIC_REPOST, KIND_NOTE, KIND_PROFILE, KIND_REACTION, KIND_RELAYS, KIND_REPOST, )) SUPPORTS_DMS = False # NIP-17 HTML_PROFILES = False HANDLES_PER_PAY_LEVEL_DOMAIN = 3 RECEIVE_FILTERS = ( filters.content_blocklisted, filters.domain_blocklisted, filters.duplicate_content, filters.media_blocklisted, ) RATE_LIMIT_TYPE = memcache.RateLimitType.EXPONENTIAL relays = ndb.KeyProperty(kind='Object') """NIP-65 kind 10002 event with this user's relays.""" valid_nip05 = ndb.StringProperty() """NIP-05 identifier that we've resolved and verified.""" def _pre_put_hook(self): """Validates that the id is a hex pubkey with nostr: prefix. ...and also that we aren't storing a private key for this user since we don't have their private key. """ assert self.key.id().startswith('nostr:'), self.key.id() assert ID_RE.match(self.key.id().removeprefix('nostr:')), self.key.id() assert not self._keypair('nostr'), self.key.id() return super()._pre_put_hook()
[docs] def hex_pubkey(self): """Returns the user's hex-encoded Nostr public secp256k1 key. Returns: str: """ return self.key.id().removeprefix('nostr:')
[docs] def npub(self): """Returns the user's bech32-encoded ActivityPub public secp256k1 key. Returns: str: """ return id_to_uri('npub', self.hex_pubkey()).removeprefix('nostr:')
def id_uri(self): return id_to_uri('npub', self.hex_pubkey()) @ndb.ComputedProperty def handle(self): """Returns the NIP-05 identity from the user's profile event.""" if nip05 := self.nip_05(): return nip05.removeprefix('_@') elif self.key: return self.npub() @ndb.ComputedProperty def verified_domain(self): """Returns this user's NIP-05 if it's the top-level domain, ie _@...""" if self.valid_nip05 and self.valid_nip05.startswith('_@'): return self.valid_nip05.removeprefix('_@') @ndb.ComputedProperty def status(self): if self.manual_opt_out is False: return None elif not self.obj or not self.obj.as1: return 'no-profile' # check NIP-05 nip05 = self.nip_05() if not nip05: self.valid_nip05 = None elif nip05 != self.valid_nip05: self.valid_nip05 = None try: if nip05_to_npub(nip05) == self.npub(): logger.info(f'resolved valid NIP-05 {nip05} for {self.key}') self.valid_nip05 = nip05 except BaseException as e: code, _ = util.interpret_http_exception(e) if not code: logger.info(e) if self.valid_nip05: # unset this NIP-05 on any other Nostr users that currently have it others = Nostr.query(Nostr.valid_nip05 == nip05).fetch() to_put = [] for other in others: if other.key.id() != self.key.id(): logger.info(f'removing NIP-05 {other.valid_nip05} from {other.key}') other.valid_nip05 = None to_put.append(other) ndb.put_multi(to_put) if not self.valid_nip05 or self.valid_nip05 != self.nip_05(): return 'no-nip05' return super().status def nip_05(self): if not self.obj: return if self.obj.nostr: assert self.obj.nostr.get('kind') == KIND_PROFILE content = json_loads(self.obj.nostr.get('content', '{}')) if nip05 := content.get('nip05'): return nip05 elif self.obj.as1: if username := self.obj.as1.get('username'): if '@' not in username: username = '_@' + username return username def web_url(self): if self.key: return granary.nostr.Nostr.user_url(self.npub()) def is_profile(self, obj): if super().is_profile(obj): return True if (obj and obj.nostr and obj.nostr.get('pubkey') == self.hex_pubkey() and obj.nostr.get('kind') == KIND_PROFILE): return True @classmethod def owns_id(cls, id): if id.startswith('nostr:'): return True elif is_bech32(id) or ID_RE.match(id): return None return False @classmethod def owns_handle(cls, handle, allow_internal=False): if not handle: return False # TODO: implement allow_internal? if (handle.startswith('npub') or cls.is_user_at_domain(handle, allow_internal=True)): return True if web.is_valid_domain(handle): return None # could be a _@ NIP-05 return False @classmethod def handle_to_id(cls, handle): if cls.owns_handle(handle) is False: return None elif handle.startswith('npub'): return handle try: npub = nip05_to_npub(handle) except ValueError as e: logger.info(e) return None except BaseException as e: code, _ = util.interpret_http_exception(e) if code: return None raise return 'nostr:' + uri_to_id(npub) @classmethod def bridged_web_url_for(cls, user, fallback=False): if not isinstance(user, cls): if id := user.get_copy(cls): return granary.nostr.Nostr.user_url( id_to_uri('npub', id).removeprefix('nostr:'))
[docs] @classmethod def target_for(cls, obj, shared=False): """Returns the first NIP-65 relay for the given object's author.""" if obj and (id := as1.get_owner(obj.as1)) and id.startswith('nostr:'): if user := Nostr.get_or_create(id, allow_opt_out=True): if user.relays and (relays := user.relays.get()): if relays.nostr: for tag in relays.nostr.get('tags', []): if tag[0] == 'r' and (len(tag) == 2 or tag[2] == 'write'): return normalize_relay_uri(tag[1])
[docs] @classmethod def check_supported(cls, obj, direction): """Update is only supported for actors and articles, not notes.""" super().check_supported(obj, direction) if direction == 'send': if obj.type == 'update': if inner_type := as1.object_type(as1.get_object(obj.as1)): if inner_type not in list(as1.ACTOR_TYPES) + ['article']: error(f"Bridgy Fed for {cls.LABEL} doesn't support {obj.type} {inner_type} yet", status=204)
[docs] @classmethod def create_for(cls, user): """Creates a Nostr profile for a non-Nostr user. Args: user (models.User) """ assert not isinstance(user, cls) if npub := user.get_copy(cls): return logger.info(f'adding Nostr copy user {user.npub()} for {user.key}') user.add('copies', Target(uri='nostr:' + user.hex_pubkey(), protocol='nostr')) user.put() # create profile (kind 0) and relays (kind 10002) events if necessary if user.obj and user.obj.get_copy(Nostr): return if not user.obj.as1: user.reload_profile() cls.send(user.obj, cls.DEFAULT_TARGET, from_user=user)
[docs] def reload_profile(self, **kwargs): """Reloads this user's kind 0 profile, NIP-65 relay list, and NIP-05 id. https://nips.nostr.com/1#kinds https://nips.nostr.com/65 https://nips.nostr.com/5 """ client = granary.nostr.Nostr() relay = normalize_relay_uri(self.target_for(self.obj) or self.DEFAULT_TARGET) logger.debug(f'connecting to {relay}') with util.websocket_connect(relay, open_timeout=util.HTTP_TIMEOUT, close_timeout=util.HTTP_TIMEOUT) as websocket: events = client.query(websocket, { 'authors': [self.hex_pubkey()], 'kinds': [KIND_PROFILE, KIND_RELAYS], }) profile = relays = None for event in events: kind = event.get('kind') if kind == KIND_PROFILE and not profile: profile = Object.get_or_create('nostr:' + event['id'], nostr=event, source_protocol='nostr', authed_as=self.key.id()) self.obj_key = profile.key elif kind == KIND_RELAYS and not relays: relays = Object.get_or_create('nostr:' + event['id'], nostr=event, source_protocol='nostr', authed_as=self.key.id()) self.relays = relays.key if profile and relays: break # re-checks NIP-05 in status() self.put()
[docs] @classmethod def set_username(to_cls, user, username): """check NIP-05 DNS, then update profile event with nip05?""" if not user.is_enabled(to_cls): raise ValueError("First, you'll need to bridge your account into Nostr by following this account.") npub = user.get_copy(to_cls) username = username.removeprefix('@') # TODO logger.info(f'Setting Nostr NIP-05 for {user.key.id()} to {username}') raise NotImplementedError()
[docs] @classmethod def fetch(cls, obj, **kwargs): """Fetches a Nostr event from a relay. Args: obj (models.Object): with the id to fetch. Fills data into the ``nostr`` property. kwargs: ignored Returns: bool: True if the object was fetched and populated successfully, False otherwise """ if not cls.owns_id(obj.key.id()): logger.info(f"Nostr can't fetch {obj.key.id()}") return False id = obj.key.id().removeprefix('nostr:') client = granary.nostr.Nostr() relay = normalize_relay_uri(cls.target_for(obj) or cls.DEFAULT_TARGET) assert relay logger.debug(f'connecting to {relay}') with util.websocket_connect(relay, open_timeout=util.HTTP_TIMEOUT, close_timeout=util.HTTP_TIMEOUT) as websocket: events = client.query(websocket, {'ids': [id]}) if not events: return False obj.nostr = events[0] return True
[docs] @classmethod def _convert(to_cls, obj, from_user=None, **kwargs): """Converts a :class:`models.Object` to a Nostr event. Args: obj (models.Object) from_user (models.User): user this object is from kwargs: unused Returns: dict: JSON Nostr event """ if obj.nostr: return obj.nostr obj_as1 = obj.as1 translated = to_cls.translate_ids(obj_as1) is_user = from_user and from_user.is_profile(obj) if is_user: # username gets set as nip05 translated['username'] = from_user.handle_as(Nostr) # find first relay (target) for referenced user (follow of, in reply to, # repost of) if as1.object_type(obj_as1) in as1.CRUD_VERBS: obj_as1 = as1.get_object(obj_as1) remote_relay = '' if remote_obj := granary.nostr.Nostr().base_object(obj_as1): if id := remote_obj.get('id'): base_obj = Nostr.load(id) remote_relay = to_cls.target_for(base_obj) or '' # NIP-48 proxy tag with original protocol's id proxy_tag = None if ((orig_id := obj_as1.get('id')) and not orig_id.startswith('nostr:') and obj.source_protocol not in (None, 'ui')): if is_user: orig_id = from_user.id_uri() proxy_tag = [orig_id, obj.source_protocol] # convert! privkey = from_user.nsec() if from_user else None event = granary.nostr.from_as1(translated, privkey=privkey, remote_relay=remote_relay, proxy_tag=proxy_tag) # for outbound follows (kind 3 events), include *all* followed users if (from_user and obj.type == 'follow' and obj.as1.get('actor') == from_user.key.id()): logging.info(f"adding all of {from_user.key.id()}'s follows") # TODO: limit for follower in Follower.query( Follower.from_ == from_user.key, # Follower.to._kind == Nostr, Follower.to >= ndb.Key('Nostr', chr(0)), Follower.to < ndb.Key('Nosts', chr(0)), Follower.status == 'active'): pubkey = follower.to.id().removeprefix('nostr:') util.add(event['tags'], ['p', pubkey, remote_relay or '', '']) # override d tag (if any) based on original protocol-native id, not # translated Nostr event id event_orig_ids = granary.nostr.from_as1(obj.as1) for tag in event_orig_ids['tags']: if len(tag) >= 2 and tag[0] == 'd': # override d tag with this one event['tags'] = [tag] + [t for t in event['tags'] if t[0] != 'd'] if privkey: event.pop('id', None) event.pop('sig', None) id_and_sign(event, privkey) else: event['id'] = id_for(event) return event
[docs] @classmethod def send(to_cls, obj, relay_url, from_user=None, **kwargs): """Sends an event to a relay. Events are immutable, so all operations happen by sending a new event, including updates and deletes. :meth:`granary.nostr.from_as1` translates all of those, so all we have to do here is convert and send the event. """ relay_url = normalize_relay_uri(relay_url) assert obj assert from_user assert obj.source_protocol != 'nostr' if obj.type in ('post', 'update'): if not (obj := to_cls.load(as1.get_id(obj.as1, 'object'))): return False # store and reuse converted Nostr event across sends. granary.nostr.from_as1 # sets created_at to now, so if we convert fresh each time, we'll generate # new events with different ids. if not obj.nostr: @ndb.transactional() def convert(): nonlocal obj # read_consistency=ndb.STRONG shouldn't be necessary here, but oddly # it is, ndb seems to use cache inside txes even though it shouldn't # https://github.com/googleapis/python-ndb/issues/751 # https://github.com/googleapis/python-ndb/issues/888 ? obj = obj.key.get(read_consistency=ndb.STRONG) or obj if not obj.nostr: obj.nostr = to_cls.convert(obj, from_user=from_user) obj.put() convert() event = obj.nostr assert event assert event.get('pubkey') == from_user.hex_pubkey(), (event, from_user.key) assert event.get('sig'), event id = event['id'] events = [event] # if this is a profile event, add a relays event if event['kind'] == KIND_PROFILE: events.append(id_and_sign({ 'kind': KIND_RELAYS, 'pubkey': from_user.hex_pubkey(), 'tags': [['r', to_cls.DEFAULT_TARGET]], 'content': '', }, from_user.nsec())) logger.debug(f'connecting to {relay_url}') with util.websocket_connect(relay_url, open_timeout=util.HTTP_TIMEOUT, close_timeout=util.HTTP_TIMEOUT) as websocket: try: for event in events: msg = ['EVENT', event] logger.debug(f'{websocket.remote_address} <= {event}') websocket.send(json_dumps(msg)) resp = websocket.recv(timeout=util.HTTP_TIMEOUT) logger.debug(f'{websocket.remote_address} => {resp}') resp = json_loads(resp) if resp[:3] != ['OK', event['id'], True]: logger.warning('relay rejected event!') return False except ConnectionClosedOK as cc: logger.warning(cc) return False if obj.type in STORE_AS1_TYPES: @ndb.transactional() def add_copy(): # read_consistency=ndb.STRONG shouldn't be necessary here, but oddly # it is, ndb seems to use cache inside txes even though it shouldn't # https://github.com/googleapis/python-ndb/issues/751 # https://github.com/googleapis/python-ndb/issues/888 ? o = obj.key.get(read_consistency=ndb.STRONG) or obj o.remove_copies_on(to_cls) o.add('copies', Target(uri='nostr:' + id, protocol=to_cls.LABEL)) o.put() add_copy() return True
[docs] @app.get('/.well-known/nostr.json') @memcache.memoize(expire=timedelta(hours=1), key=lambda: request.args.to_dict()) @flask_util.headers(common.CACHE_CONTROL) def nip_05(): """NIP-05 endpoint that serves handles for users bridged into Nostr. https://nips.nostr.com/5 Query params: name (str): should only contain a-z0-9-_. Returns a JSON object with: names: {<name>: <pubkey hex>} relays: optional, {<pubkey hex>: [relay urls]} """ name = get_required_param('name') if (proto := Protocol.for_request()) and proto != Nostr: user = proto.query(OR(proto.handle == name, proto.handle_as_domain == name, proto.key == ndb.Key(proto, name), )).get() if user and user.is_enabled(Nostr): if uri := user.get_copy(Nostr): id = uri.removeprefix('nostr:') return { 'names': {name: id}, 'relays': {id: [Nostr.DEFAULT_TARGET]}, } raise NotFound()
[docs] def normalize_relay_uri(uri): """Returns a normalized relay URI. Right now, just adds a trailing slash if the URI has no path, and removes the port if it's explicitly provided and redundant, ie ``:443`` for ``wss://`` or ``:80`` for ``ws://``. https://github.com/nostr-protocol/nips/issues/1876 https://github.com/nostr-protocol/nips/issues/1198 Args: uri (str) Returns: str: normalized URI """ if not uri or Nostr.is_blocklisted(uri): return None parsed = urlparse(uri) # remove redundant port if ((parsed.scheme == 'wss' and parsed.port == 443) or (parsed.scheme == 'ws' and parsed.port == 80)): netloc = parsed.hostname else: netloc = parsed.netloc # add trailing slash if no path path = parsed.path or '/' return urlunparse(parsed._replace(netloc=netloc, path=path))