diff --git a/examples/nip46_client_post.py b/examples/nip46_client_post.py new file mode 100644 index 0000000..cd530f3 --- /dev/null +++ b/examples/nip46_client_post.py @@ -0,0 +1,34 @@ +import asyncio +import logging +from monstr.signing.nip46 import NIP46Signer +from monstr.client.client import Client +from monstr.event.event import Event + +# url to relay used for talking to the signer +RELAY = 'ws://localhost:8080' + + +async def do_post(url, text): + """ + Example showing how to post a text note (Kind 1) to relay + using nip46 signer - we don't have access locally to the keys + """ + + # bunker://... e.g. printed out by nip46_signer_service.py + con_str = input('connection string: ').strip() + + # we'll make post and they'll be signed by the bunker at con_str + my_signer = NIP46Signer(connection=con_str) + + # from here it's just a signer interface same as if we were using BasicKeySigner + async with Client(url) as c: + n_msg = await my_signer.ready_post(Event(kind=Event.KIND_TEXT_NOTE, + content=text)) + c.publish(n_msg) + + +if __name__ == "__main__": + logging.getLogger().setLevel(logging.DEBUG) + text = 'hello using NIP46 signer' + + asyncio.run(do_post(RELAY, text)) diff --git a/examples/nip46_signer_service.py b/examples/nip46_signer_service.py new file mode 100644 index 0000000..bd4c4e9 --- /dev/null +++ b/examples/nip46_signer_service.py @@ -0,0 +1,31 @@ +import asyncio +import logging +from monstr.encrypt import Keys +from monstr.signing.nip46 import NIP46ServerConnection +from monstr.signing.signing import BasicKeySigner + +# url to relay used for talking to the signer +RELAY = 'ws://localhost:8080' + + +async def run_signer(): + """ + Run a service that'll sign events via NIP46 for some random keys + """ + # rnd generate some keys + n_keys = Keys() + + # create the signing service + my_signer = NIP46ServerConnection(signer=BasicKeySigner(n_keys), + relay=RELAY) + + # output info needed for client to connect to the signer + print(await my_signer.bunker_url) + + # wait forever... + while True: + await asyncio.sleep(0.1) + +if __name__ == "__main__": + logging.getLogger().setLevel(logging.DEBUG) + asyncio.run(run_signer()) diff --git a/examples/post_pow.py b/examples/post_pow.py index 5a345fb..7ea16e2 100644 --- a/examples/post_pow.py +++ b/examples/post_pow.py @@ -3,7 +3,7 @@ from monstr.client.client import Client, ClientPool from monstr.encrypt import Keys from monstr.event.event import Event -from monstr.signing import BasicKeySigner +from monstr.signing.signing import BasicKeySigner async def do_post(url, text, target): diff --git a/examples/post_signer.py b/examples/post_signer.py index 9d328b2..c9d9b08 100644 --- a/examples/post_signer.py +++ b/examples/post_signer.py @@ -19,9 +19,7 @@ async def do_post(url, text): async with Client(url) as c: n_msg = await my_signer.ready_post(Event(kind=Event.KIND_TEXT_NOTE, content=text)) - await my_signer.sign_event(n_msg) c.publish(n_msg) - # await asyncio.sleep(1) if __name__ == "__main__": logging.getLogger().setLevel(logging.DEBUG) diff --git a/src/monstr/__about__.py b/src/monstr/__about__.py index d2b36e8..92e0ab5 100644 --- a/src/monstr/__about__.py +++ b/src/monstr/__about__.py @@ -1 +1 @@ -__version__='0.1.7' \ No newline at end of file +__version__='0.1.8' \ No newline at end of file diff --git a/src/monstr/client/client.py b/src/monstr/client/client.py index 90a0f38..3ec3b14 100644 --- a/src/monstr/client/client.py +++ b/src/monstr/client/client.py @@ -745,7 +745,7 @@ class ClientPool: """ def __init__(self, - clients: str | Client, + clients: str | Client | list[str | Client], on_connect: Callable = None, on_status: Callable = None, on_eose: Callable = None, @@ -785,7 +785,7 @@ def __init__(self, self._on_status = on_status # for whatever reason using pool but only a single client handed in - if isinstance(clients, str): + if isinstance(clients, (str, Client)): clients = [clients] # ssl if disabled - will be disabled for all clients diff --git a/src/monstr/encrypt.py b/src/monstr/encrypt.py index e392990..eef7dff 100644 --- a/src/monstr/encrypt.py +++ b/src/monstr/encrypt.py @@ -271,6 +271,7 @@ def decrypt_event(self, evt: Event) -> Event: class DecryptionException(Exception): pass + class NIP4Encrypt(Encrypter): def __init__(self, key: Keys | str): diff --git a/src/monstr/signing/nip46.py b/src/monstr/signing/nip46.py index 15c9076..3b31a2f 100644 --- a/src/monstr/signing/nip46.py +++ b/src/monstr/signing/nip46.py @@ -3,11 +3,12 @@ import asyncio import json from json import JSONDecodeError +from urllib.parse import urlparse, parse_qs from monstr.client.client import ClientPool, Client from monstr.encrypt import Keys from monstr.event.event import Event from monstr.client.event_handlers import EventHandler, DeduplicateAcceptor -from monstr.signing.signing import SignerInterface +from monstr.signing.signing import SignerInterface, BasicKeySigner from monstr.util import util_funcs @@ -26,11 +27,6 @@ class NIP46ServerConnection(EventHandler): this is the server side of a NIP46 signing process. It'll listens and signs events on behalf of a client that requests it so that the client never holds the keys itself. - - TODO: add hooks for funcs to allow/disallow event sign - also write the client side which will be implement our signing interface via NIP46 which - will allow us to replace anywhere that can use BasicKeySigner with a NIP46 version - """ def __init__(self, signer: SignerInterface, @@ -40,10 +36,12 @@ def __init__(self, self._signer = signer self._comm_k = comm_k + self._relay = relay - self._authoriser = authoriser + if isinstance(relay, str): + self._relay = [relay] - self._run = True + self._authoriser = authoriser # TODO - make client so that if it gets async for _on_connect, _do_event # it'll automaticlly create as task @@ -59,6 +57,7 @@ async def _aon_connect(my_client:Client): } ) + self._run = True self._client = ClientPool(self._relay, on_connect=_on_connect) @@ -67,14 +66,20 @@ async def _aon_connect(my_client:Client): # start a process to work on the queued events self._event_process_task = asyncio.create_task(self._my_event_consumer()) + # TODO: we should probably have start and end, etc as client + # not just start a task here... asyncio.create_task(self._client.run()) super().__init__(event_acceptors=[ DeduplicateAcceptor() ]) + @property + async def bunker_url(self): + return f'bunker://{await self._signer.get_public_key()}?relay={"&".join(self._relay)}' + async def describe(self): - await self._client.wait_connect() + # await self._client.wait_connect() sign_k = await self._signer.get_public_key() content = json.dumps({ @@ -152,9 +157,6 @@ async def _do_command(self, method: str, params: [str]): async def request_connect(self): await self._do_command('connect', [await self._signer.get_public_key()]) - # async def do_ack(self): - # await self._do_response(result=) - async def connect(self, id: str, params: [str]): if self._comm_k is None: self._comm_k = params[0] @@ -162,14 +164,6 @@ async def connect(self, id: str, params: [str]): await self._do_response(result=await self._signer.get_public_key(), id=id) - # async def describe(self, id: str, params: [str]): - # await self._do_response(result=['describe', - # 'get_public_key', - # 'sign_event', - # 'nip04_decrypt', - # 'connect'], - # id=id) - async def get_public_key(self, id: str, params: [str]): await self._do_response(result=await self._signer.get_public_key(), id=id) @@ -297,4 +291,172 @@ async def ado_event(self, the_client: Client, sub_id, evt: Event): def end(self): self._run = False - self._client.end() \ No newline at end of file + self._client.end() + + +class NIP46Comm(EventHandler): + + def __init__(self, + client: str | Client | list[str | Client], + for_k: str, + signer: SignerInterface): + + self._signer = signer + self._comm_k = None + self._for_k = for_k + + # events queued and dealt with serially as they come in + self._event_q: asyncio.Queue = asyncio.Queue() + # start a process to work on the queued events + self._event_process_task = asyncio.create_task(self._my_event_consumer()) + + self._client = ClientPool(client) + asyncio.create_task(self._client.run()) + self._run = False + + super().__init__(event_acceptors=[ + DeduplicateAcceptor() + ]) + + async def connect(self, timeout=5) -> bool: + if self._comm_k is None: + await self.do_command(method='connect', + params=[await self._signer.get_public_key()]) + + return self._comm_k is not None + + async def _get_msg_event(self, content: str, to_k: str) -> Event: + # encrypt the content + content = await self._signer.nip4_encrypt(content, + to_pub_k=to_k) + # make the event + ret = Event(pub_key=await self._signer.get_public_key(), + kind=Event.KIND_NIP46, + content=content, + tags=[ + ['p', to_k] + ] + ) + # and sign it + await self._signer.sign_event(ret) + + return ret + + async def do_command(self, method: str, params: [str]): + id = util_funcs.get_rnd_hex_str(8) + + if method == 'connect': + to_k = self._for_k + else: + to_k = self._comm_k + if to_k is not None: + evt = await self._get_msg_event( + content=json.dumps({ + 'id': id, + 'method': method, + 'params': params + }), + to_k=to_k + ) + + self._client.publish(evt) + else: + print('something went wrong... maybe we did not connect yet') + + return id + + def do_event(self, the_client: Client, sub_id, evt: Event): + pass + + async def ado_event(self, the_client: Client, sub_id, evt: Event): + pass + + async def _my_event_consumer(self): + while self._run: + try: + args = await self._event_q.get() + await self.ado_event(*args) + + except Exception as e: + logging.debug(f'NIP46Comm::_my_event_consumer: {e}') + + def start(self): + asyncio.create_task(self._client.run()) + + def end(self): + self._run = False + self._client.end() + +class NIP46Signer(SignerInterface): + """ + signer that proxies signing via NIP46 - it never has access to the keys itself + + for now expects connection str, todo add where we initialise the connection + """ + def __init__(self, connection: str): + parsed = urlparse(connection) + + if parsed.scheme != 'bunker': + raise SignerException(f'NIP46Signer::__init__: unknown connection scheme {parsed.scheme}') + + # keys we'll be signing as + for_pub_k = parsed.netloc + if not Keys.is_valid_key(for_pub_k): + raise SignerException(f'NIP46Signer::__init__: bad key {for_pub_k}') + self._for_pub_k = for_pub_k + + query_parsed = parse_qs(parsed.query) + if 'relay' not in query_parsed: + raise SignerException(f'NIP46Signer::__init__: relay not supplied') + + # probably we should do more checks on the relay urls.... + self._relay = query_parsed['relay'] + + # does the comm between us and the NIP46 server, we use ephemeral keys for signing + self._comm = NIP46Comm(client=self._relay, + for_k=for_pub_k, + signer=BasicKeySigner(Keys())) + + async def _get_comm_key(self): + await self._comm.do_command(method='connect', + params=[]) + + async def _do_method(self, method: str, args: list) -> Event: + logging.debug(f'NIP46Signer::_do_method: {method} - {args}') + # did we already get key to connect on? + await self._comm.connect() + + async def get_public_key(self) -> str: + # we know the pub k so won't bother calling signer service for this + return self._for_pub_k + + async def sign_event(self, evt: Event): + await self._do_method('sign_event', [json.dumps(evt.data())]) + + async def echd_key(self, to_pub_k: str) -> str: + pass + + async def nip4_encrypt(self, plain_text: str, to_pub_k: str) -> str: + pass + + async def nip4_decrypt(self, payload: str, for_pub_k: str) -> str: + pass + + async def nip4_encrypt_event(self, evt: Event, to_pub_k: str) -> Event: + pass + + async def nip4_decrypt_event(self, evt: Event) -> Event: + pass + + async def nip44_encrypt(self, plain_text: str, to_pub_k: str, version=2) -> str: + pass + + async def nip44_decrypt(self, payload: str, for_pub_k: str) -> str: + pass + + async def nip44_encrypt_event(self, evt: Event, to_pub_k: str) -> Event: + pass + + async def nip44_decrypt_event(self, evt: Event) -> Event: + pass +