diff --git a/duniterpy/api/ws2p/network.py b/duniterpy/api/ws2p/network.py index e39e6912b95a785af6facb5ace86547d5f405f03..6774bcc77ed89508f307a1a619d08ca05d950947 100644 --- a/duniterpy/api/ws2p/network.py +++ b/duniterpy/api/ws2p/network.py @@ -69,7 +69,8 @@ WS2P_CONNECT_MESSAGE_SCHEMA = { "type": "object", "properties": { "auth": { - "type": "string" + "type": "string", + "pattern": "^CONNECT$" }, "challenge": { "type": "string", @@ -83,5 +84,37 @@ WS2P_CONNECT_MESSAGE_SCHEMA = { "sig": { "type": "string", }, - } + }, + "required": ["auth", "challenge", "currency", "pub", "sig"] +} + +WS2P_ACK_MESSAGE_SCHEMA = { + "type": "object", + "properties": { + "auth": { + "type": "string", + "pattern": "^ACK$" + }, + "pub": { + "type": "string", + }, + "sig": { + "type": "string", + } + }, + "required": ["auth", "pub", "sig"] +} + +WS2P_OK_MESSAGE_SCHEMA = { + "type": "object", + "properties": { + "auth": { + "type": "string", + "pattern": "^OK$" + }, + "sig": { + "type": "string", + } + }, + "required": ["auth", "sig"] } diff --git a/duniterpy/documents/ws2p/messages.py b/duniterpy/documents/ws2p/messages.py new file mode 100644 index 0000000000000000000000000000000000000000..62d3d8d88f2e36376e46f3a42d73a2110be24ae8 --- /dev/null +++ b/duniterpy/documents/ws2p/messages.py @@ -0,0 +1,169 @@ +import json +import uuid +from typing import Optional + +from duniterpy.documents import Document +from duniterpy.key import VerifyingKey, SigningKey + + +class Connect(Document): + version = 2 + auth = "CONNECT" + + def __init__(self, currency: str, pubkey: str, challenge: Optional[str] = None, + signature: Optional[str] = None) -> None: + """ + Init Connect message document + + :param currency: Name of currency + :param pubkey: Public key of node + :param challenge: [Optional, default=None] Big random string, typically an uuid + :param signature: [Optional, default=None] Base64 encoded signature of raw formated document + """ + super().__init__(self.version, currency, [signature]) + + self.pubkey = pubkey + if challenge is None: + # create challenge + self.challenge = uuid.uuid4().hex + uuid.uuid4().hex + else: + self.challenge = challenge + # add and verify signature + if signature is not None: + self.signatures.append(signature) + verifying_key = VerifyingKey(self.pubkey) + verifying_key.verify_document(self) + + def raw(self): + """ + Return the document in raw format + + :return: + """ + return "WS2P:CONNECT:{currency}:{pub}:{challenge}".format(currency=self.currency, pub=self.pubkey, + challenge=self.challenge) + + def get_signed_json(self, signing_key: SigningKey) -> str: + """ + Return the signed document in json format + + :param signing_key: Signing key instance + + :return: + """ + self.sign([signing_key]) + data = { + "auth": self.auth, + "pub": self.pubkey, + "challenge": self.challenge, + "sig": self.signatures[0] + } + return json.dumps(data) + + def __str__(self) -> str: + return self.raw() + + +class Ack(Document): + version = 2 + auth = "ACK" + + def __init__(self, currency: str, pubkey: str, challenge: str, + signature: Optional[str] = None) -> None: + """ + Init Connect message document + + :param currency: Name of currency + :param pubkey: Public key of node + :param challenge: The challenge sent in the connect message + :param signature: [Optional, default=None] Base64 encoded signature of raw formated document + """ + super().__init__(self.version, currency, [signature]) + + self.pubkey = pubkey + self.challenge = challenge + # add and verify signature + if signature is not None: + self.signatures.append(signature) + verifying_key = VerifyingKey(self.pubkey) + verifying_key.verify_document(self) + + def raw(self): + """ + Return the document in raw format + + :return: + """ + return "WS2P:ACK:{currency}:{pub}:{challenge}".format(currency=self.currency, pub=self.pubkey, + challenge=self.challenge) + + def get_signed_json(self, signing_key: SigningKey) -> str: + """ + Return the signed document in json format + + :param signing_key: Signing key instance + + :return: + """ + self.sign([signing_key]) + data = { + "auth": self.auth, + "pub": self.pubkey, + "sig": self.signatures[0] + } + return json.dumps(data) + + def __str__(self) -> str: + return self.raw() + + +class Ok(Document): + version = 2 + auth = "OK" + + def __init__(self, currency: str, pubkey: str, challenge: str, + signature: Optional[str] = None) -> None: + """ + Init Connect message document + + :param currency: Name of currency + :param pubkey: Public key of node + :param challenge: The challenge sent in the connect message + :param signature: [Optional, default=None] Base64 encoded signature of raw formated document + """ + super().__init__(self.version, currency, [signature]) + + self.pubkey = pubkey + self.challenge = challenge + # add and verify signature + if signature is not None: + self.signatures.append(signature) + verifying_key = VerifyingKey(self.pubkey) + verifying_key.verify_document(self) + + def raw(self): + """ + Return the document in raw format + + :return: + """ + return "WS2P:OK:{currency}:{pub}:{challenge}".format(currency=self.currency, pub=self.pubkey, + challenge=self.challenge) + + def get_signed_json(self, signing_key: SigningKey) -> str: + """ + Return the signed document in json format + + :param signing_key: Signing key instance + + :return: + """ + self.sign([signing_key]) + data = { + "auth": self.auth, + "sig": self.signatures[0] + } + return json.dumps(data) + + def __str__(self) -> str: + return self.raw() diff --git a/examples/request_web_socket_ws2p.py b/examples/request_web_socket_ws2p.py deleted file mode 100644 index f4f5424586615384ff1e3238147da4dce3b1ef7d..0000000000000000000000000000000000000000 --- a/examples/request_web_socket_ws2p.py +++ /dev/null @@ -1,76 +0,0 @@ -import asyncio -from _socket import gaierror - -import aiohttp -import jsonschema - -from duniterpy.api import ws2p -from duniterpy.api.client import Client, parse_text - -# CONFIG ####################################### - -# You can either use a complete defined endpoint : [NAME_OF_THE_API] [DOMAIN] [IPv4] [IPv6] [PORT] -# or the simple definition : [NAME_OF_THE_API] [DOMAIN] [PORT] -# Here we use the WS2P API (WS2P) -WS2P_ENDPOINT = "WS2P 2f731dcd 127.0.0.1 20900" - - -################################################ - - -async def main(): - """ - Main code - """ - # Create Client from endpoint string in Duniter format - client = Client(WS2P_ENDPOINT) - - try: - # Create Web Socket connection on block path - ws_connection = client.connect_ws() - - # From the documentation ws_connection should be a ClientWebSocketResponse object... - # - # https://docs.aiohttp.org/en/stable/client_quickstart.html#websockets - # - # In reality, aiohttp.session.ws_connect() returns a aiohttp.client._WSRequestContextManager instance. - # It must be used in a with statement to get the ClientWebSocketResponse instance from it (__aenter__). - # At the end of the with statement, aiohttp.client._WSRequestContextManager.__aexit__ is called - # and close the ClientWebSocketResponse in it. - connect_message = """ - """ - #await ws_connection.send(connect_message) - - # Mandatory to get the "for msg in ws" to work ! - async with ws_connection as ws: - print("Connected successfully to web socket block path") - - # Iterate on each message received... - async for msg in ws: - # if message type is text... - if msg.type == aiohttp.WSMsgType.TEXT: - print("Received a message") - # Validate jsonschema and return a the json dict - data = parse_text(msg.data, ws2p.network.WS2P_CONNECT_MESSAGE_SCHEMA) - print(data) - elif msg.type == aiohttp.WSMsgType.CLOSED: - # Connection is closed - print("Web socket connection closed !") - elif msg.type == aiohttp.WSMsgType.ERROR: - # Connection error - print("Web socket connection error !") - - # Close session - await client.close() - - except (aiohttp.WSServerHandshakeError, ValueError) as e: - print("Websocket block {0} : {1}".format(type(e).__name__, str(e))) - except (aiohttp.ClientError, gaierror, TimeoutError) as e: - print("{0} : {1}".format(str(e), WS2P_ENDPOINT)) - except jsonschema.ValidationError as e: - print("{:}:{:}".format(str(e.__class__.__name__), str(e))) - - -# Latest duniter-python-api is asynchronous and you have to use asyncio, an asyncio loop and a "as" on the data. -# ( https://docs.python.org/3/library/asyncio.html ) -asyncio.get_event_loop().run_until_complete(main()) diff --git a/examples/request_ws2p.py b/examples/request_ws2p.py new file mode 100644 index 0000000000000000000000000000000000000000..7d4375d8888d223facb31e11e1d16f98034cf2bd --- /dev/null +++ b/examples/request_ws2p.py @@ -0,0 +1,134 @@ +import asyncio + +from _socket import gaierror + +import aiohttp +import jsonschema + +from duniterpy.api import ws2p +from duniterpy.documents.ws2p.messages import Connect, Ack, Ok +from duniterpy.api.client import Client, parse_text + +# CONFIG ####################################### + +# You can either use a complete defined endpoint : [NAME_OF_THE_API] [DOMAIN] [IPv4] [IPv6] [PORT] +# or the simple definition : [NAME_OF_THE_API] [DOMAIN] [PORT] +# Here we use the WS2P API (WS2P) +from duniterpy.key import SigningKey + +WS2P_ENDPOINT = "WS2P 2f731dcd 127.0.0.1 20900" +CURRENCY = "g1-test" + + +################################################ + + +async def main(): + """ + Main code + """ + + # # prompt hidden user entry + # salt = getpass.getpass("Enter your passphrase (salt): ") + # + # # prompt hidden user entry + # password = getpass.getpass("Enter your password: ") + salt = password = "toto" + + # init signing_key instance + signing_key = SigningKey.from_credentials(salt, password) + + connect_document = Connect(CURRENCY, signing_key.pubkey) + connect_message = connect_document.get_signed_json(signing_key) + + # Create Client from endpoint string in Duniter format + client = Client(WS2P_ENDPOINT) + + try: + # Create Web Socket connection on block path + ws_connection = client.connect_ws() + + # From the documentation ws_connection should be a ClientWebSocketResponse object... + # + # https://docs.aiohttp.org/en/stable/client_quickstart.html#websockets + # + # In reality, aiohttp.session.ws_connect() returns a aiohttp.client._WSRequestContextManager instance. + # It must be used in a with statement to get the ClientWebSocketResponse instance from it (__aenter__). + # At the end of the with statement, aiohttp.client._WSRequestContextManager.__aexit__ is called + # and close the ClientWebSocketResponse in it. + + # Mandatory to get the "for msg in ws" to work ! + async with ws_connection as ws: + print("Connected successfully to web socket endpoint") + + print("Send message CONNECT : " + connect_message) + await ws.send_str(connect_message) + + # Iterate on each message received... + async for msg in ws: + # if message type is text... + if msg.type == aiohttp.WSMsgType.TEXT: + print(msg.data) + try: + # Validate jsonschema and return a the json dict + data = parse_text(msg.data, ws2p.network.WS2P_CONNECT_MESSAGE_SCHEMA) + print("Received a CONNECT message") + remote_connect_document = Connect(CURRENCY, data["pub"], data["challenge"], data["sig"]) + print("Remote CONNECT message signature is valid") + + ack_message = Ack(CURRENCY, signing_key.pubkey, + remote_connect_document.challenge).get_signed_json( + signing_key) + # send ACK message + print("Send ACK message...") + await ws.send_str(ack_message) + + except jsonschema.exceptions.ValidationError: + try: + # Validate jsonschema and return a the json dict + data = parse_text(msg.data, ws2p.network.WS2P_ACK_MESSAGE_SCHEMA) + print("Received a ACK message") + + # create ACK document from ACK response to verify signature + Ack(CURRENCY, data["pub"], connect_document.challenge, data["sig"]) + print("Remote ACK message signature is valid") + # Si ACK response ok, create OK message + ok_message = Ok(CURRENCY, signing_key.pubkey, connect_document.challenge).get_signed_json( + signing_key) + + # send OK message + print("Send OK message...") + await ws.send_str(ok_message) + except jsonschema.exceptions.ValidationError: + try: + # Validate jsonschema and return a the json dict + data = parse_text(msg.data, ws2p.network.WS2P_OK_MESSAGE_SCHEMA) + print("Received a OK message") + + Ok(CURRENCY, remote_connect_document.pubkey, connect_document.challenge, data["sig"]) + print("Remote OK message signature is valid") + + except jsonschema.exceptions.ValidationError: + pass + + elif msg.type == aiohttp.WSMsgType.CLOSED: + # Connection is closed + print("Web socket connection closed !") + elif msg.type == aiohttp.WSMsgType.ERROR: + # Connection error + print("Web socket connection error !") + + # Close session + await client.close() + + except (aiohttp.WSServerHandshakeError, ValueError) as e: + print("Websocket handshake {0} : {1}".format(type(e).__name__, str(e))) + except (aiohttp.ClientError, gaierror, TimeoutError) as e: + print("{0} : {1}".format(str(e), WS2P_ENDPOINT)) + except jsonschema.ValidationError as e: + print("{:}:{:}".format(str(e.__class__.__name__), str(e))) + + +# Latest duniter-python-api is asynchronous and you have to use asyncio, an asyncio loop and a "as" on the data. +# ( https://docs.python.org/3/library/asyncio.html ) +asyncio.get_event_loop().run_until_complete(main())