From 384a7db974fadd3c59456ad86871ce8bc9ffb7b2 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 19 Aug 2021 14:10:37 +0200 Subject: [PATCH] Implemented link peer identification --- Examples/Identify.py | 310 +++++++++++++++++++++++++++++++++++++++++++ RNS/Link.py | 52 ++++++++ RNS/Packet.py | 3 +- RNS/Transport.py | 2 + RNS/__init__.py | 7 +- RNS/_version.py | 1 + setup.py | 4 +- 7 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 Examples/Identify.py create mode 100644 RNS/_version.py diff --git a/Examples/Identify.py b/Examples/Identify.py new file mode 100644 index 0000000..4c31ba5 --- /dev/null +++ b/Examples/Identify.py @@ -0,0 +1,310 @@ +########################################################## +# This RNS example demonstrates how to set up a link to # +# a destination, and identify the initiator to it's peer # +########################################################## + +import os +import sys +import time +import argparse +import RNS + +# Let's define an app name. We'll use this for all +# destinations we create. Since this echo example +# is part of a range of example utilities, we'll put +# them all within the app namespace "example_utilities" +APP_NAME = "example_utilities" + +########################################################## +#### Server Part ######################################### +########################################################## + +# A reference to the latest client link that connected +latest_client_link = None + +# This initialisation is executed when the users chooses +# to run as a server +def server(configpath): + # We must first initialise Reticulum + reticulum = RNS.Reticulum(configpath) + + # Randomly create a new identity for our link example + server_identity = RNS.Identity() + + # We create a destination that clients can connect to. We + # want clients to create links to this destination, so we + # need to create a "single" destination type. + server_destination = RNS.Destination( + server_identity, + RNS.Destination.IN, + RNS.Destination.SINGLE, + APP_NAME, + "identifyexample" + ) + + # We configure a function that will get called every time + # a new client creates a link to this destination. + server_destination.set_link_established_callback(client_connected) + + # Everything's ready! + # Let's Wait for client requests or user input + server_loop(server_destination) + +def server_loop(destination): + # Let the user know that everything is ready + RNS.log( + "Link identification example "+ + RNS.prettyhexrep(destination.hash)+ + " running, waiting for a connection." + ) + + RNS.log("Hit enter to manually send an announce (Ctrl-C to quit)") + + # We enter a loop that runs until the users exits. + # If the user hits enter, we will announce our server + # destination on the network, which will let clients + # know how to create messages directed towards it. + while True: + entered = input() + destination.announce() + RNS.log("Sent announce from "+RNS.prettyhexrep(destination.hash)) + +# When a client establishes a link to our server +# destination, this function will be called with +# a reference to the link. +def client_connected(link): + global latest_client_link + + RNS.log("Client connected") + link.set_link_closed_callback(client_disconnected) + link.set_packet_callback(server_packet_received) + link.set_remote_identified_callback(remote_identified) + latest_client_link = link + +def client_disconnected(link): + RNS.log("Client disconnected") + +def remote_identified(identity): + RNS.log("Remote identified as: "+str(identity)) + +def server_packet_received(message, packet): + global latest_client_link + + # Get the originating identity for display + remote_peer = "unidentified peer" + if packet.link.get_remote_identity() != None: + remote_peer = str(packet.link.get_remote_identity()) + + # When data is received over any active link, + # it will all be directed to the last client + # that connected. + text = message.decode("utf-8") + + RNS.log("Received data from "+remote_peer+": "+text) + + reply_text = "I received \""+text+"\" over the link from "+remote_peer + reply_data = reply_text.encode("utf-8") + RNS.Packet(latest_client_link, reply_data).send() + + +########################################################## +#### Client Part ######################################### +########################################################## + +# A reference to the server link +server_link = None + +# A reference to the client identity +client_identity = None + +# This initialisation is executed when the users chooses +# to run as a client +def client(destination_hexhash, configpath): + global client_identity + # We need a binary representation of the destination + # hash that was entered on the command line + try: + if len(destination_hexhash) != 20: + raise ValueError("Destination length is invalid, must be 20 hexadecimal characters (10 bytes)") + destination_hash = bytes.fromhex(destination_hexhash) + except: + RNS.log("Invalid destination entered. Check your input!\n") + exit() + + # We must first initialise Reticulum + reticulum = RNS.Reticulum(configpath) + + # Create a new client identity + client_identity = RNS.Identity() + RNS.log( + "Client created new identity "+ + str(client_identity) + ) + + # Check if we know a path to the destination + if not RNS.Transport.has_path(destination_hash): + RNS.log("Destination is not yet known. Requesting path and waiting for announce to arrive...") + RNS.Transport.request_path(destination_hash) + while not RNS.Transport.has_path(destination_hash): + time.sleep(0.1) + + # Recall the server identity + server_identity = RNS.Identity.recall(destination_hash) + + # Inform the user that we'll begin connecting + RNS.log("Establishing link with server...") + + # When the server identity is known, we set + # up a destination + server_destination = RNS.Destination( + server_identity, + RNS.Destination.OUT, + RNS.Destination.SINGLE, + APP_NAME, + "identifyexample" + ) + + # And create a link + link = RNS.Link(server_destination) + + # We set a callback that will get executed + # every time a packet is received over the + # link + link.set_packet_callback(client_packet_received) + + # We'll also set up functions to inform the + # user when the link is established or closed + link.set_link_established_callback(link_established) + link.set_link_closed_callback(link_closed) + + # Everything is set up, so let's enter a loop + # for the user to interact with the example + client_loop() + +def client_loop(): + global server_link + + # Wait for the link to become active + while not server_link: + time.sleep(0.1) + + should_quit = False + while not should_quit: + try: + print("> ", end=" ") + text = input() + + # Check if we should quit the example + if text == "quit" or text == "q" or text == "exit": + should_quit = True + server_link.teardown() + + # If not, send the entered text over the link + if text != "": + data = text.encode("utf-8") + if len(data) <= RNS.Link.MDU: + RNS.Packet(server_link, data).send() + else: + RNS.log( + "Cannot send this packet, the data size of "+ + str(len(data))+" bytes exceeds the link packet MDU of "+ + str(RNS.Link.MDU)+" bytes", + RNS.LOG_ERROR + ) + + except Exception as e: + RNS.log("Error while sending data over the link: "+str(e)) + should_quit = True + server_link.teardown() + +# This function is called when a link +# has been established with the server +def link_established(link): + # We store a reference to the link + # instance for later use + global server_link, client_identity + server_link = link + + # Inform the user that the server is + # connected + RNS.log("Link established with server, identifying to remote peer...") + + link.identify(client_identity) + +# When a link is closed, we'll inform the +# user, and exit the program +def link_closed(link): + if link.teardown_reason == RNS.Link.TIMEOUT: + RNS.log("The link timed out, exiting now") + elif link.teardown_reason == RNS.Link.DESTINATION_CLOSED: + RNS.log("The link was closed by the server, exiting now") + else: + RNS.log("Link closed, exiting now") + + RNS.Reticulum.exit_handler() + time.sleep(1.5) + os._exit(0) + +# When a packet is received over the link, we +# simply print out the data. +def client_packet_received(message, packet): + text = message.decode("utf-8") + RNS.log("Received data on the link: "+text) + print("> ", end=" ") + sys.stdout.flush() + + +########################################################## +#### Program Startup ##################################### +########################################################## + +# This part of the program runs at startup, +# and parses input of from the user, and then +# starts up the desired program mode. +if __name__ == "__main__": + try: + parser = argparse.ArgumentParser(description="Simple link example") + + parser.add_argument( + "-s", + "--server", + action="store_true", + help="wait for incoming link requests from clients" + ) + + parser.add_argument( + "--config", + action="store", + default=None, + help="path to alternative Reticulum config directory", + type=str + ) + + parser.add_argument( + "destination", + nargs="?", + default=None, + help="hexadecimal hash of the server destination", + type=str + ) + + args = parser.parse_args() + + if args.config: + configarg = args.config + else: + configarg = None + + if args.server: + server(configarg) + else: + if (args.destination == None): + print("") + parser.print_help() + print("") + else: + client(args.destination, configarg) + + except KeyboardInterrupt: + print("") + exit() \ No newline at end of file diff --git a/RNS/Link.py b/RNS/Link.py index abb83e4..5d40854 100644 --- a/RNS/Link.py +++ b/RNS/Link.py @@ -23,6 +23,7 @@ class LinkCallbacks: self.resource = None self.resource_started = None self.resource_concluded = None + self.remote_identified = None class Link: """ @@ -125,6 +126,7 @@ class Link: self.owner = owner self.destination = destination self.attached_interface = None + self.__remote_identity = None self.__encryption_disabled = False if self.destination == None: self.initiator = False @@ -226,6 +228,7 @@ class Link: if self.destination.identity.validate(signature, signed_data): self.rtt = time.time() - self.request_time self.attached_interface = packet.receiving_interface + self.__remote_identity = self.destination.identity RNS.Transport.activate_link(self) RNS.log("Link "+str(self)+" established with "+str(self.destination)+", RTT is "+str(self.rtt), RNS.LOG_VERBOSE) rtt_data = umsgpack.packb(self.rtt) @@ -243,6 +246,25 @@ class Link: RNS.log("Invalid link proof signature received by "+str(self)+". Ignoring.", RNS.LOG_DEBUG) + def identify(self, identity): + """ + Identifies the initiator of the link to the remote peer. This can only happen + once the link has been established, and is carried out over the encrypted link. + The identity is only revealed to the remote peer, and initiator anonymity is + thus preserved. This method can be used for authentication. + + :param identity: An RNS.Identity instance to identify as. + """ + if self.initiator: + signed_data = self.link_id + identity.get_public_key() + signature = identity.sign(signed_data) + proof_data = identity.get_public_key() + signature + + proof = RNS.Packet(self, proof_data, RNS.Packet.DATA, context = RNS.Packet.LINKIDENTIFY) + proof.send() + self.had_outbound() + + def rtt_packet(self, packet): try: # TODO: This is crude, we should use the delta @@ -286,6 +308,12 @@ class Link: """ return min(self.no_inbound_for(), self.no_outbound_for()) + def get_remote_identity(self): + """ + :returns: The identity of the remote peer, if it is known + """ + return self.__remote_identity + def had_outbound(self): self.last_outbound = time.time() @@ -424,6 +452,21 @@ class Link: if self.destination.callbacks.proof_requested: self.destination.callbacks.proof_requested(packet) + elif packet.context == RNS.Packet.LINKIDENTIFY: + plaintext = self.decrypt(packet.data) + + if not self.initiator and len(plaintext) == RNS.Identity.KEYSIZE//8 + RNS.Identity.SIGLENGTH//8: + public_key = plaintext[:RNS.Identity.KEYSIZE//8] + signed_data = self.link_id+public_key + signature = plaintext[RNS.Identity.KEYSIZE//8:RNS.Identity.KEYSIZE//8+RNS.Identity.SIGLENGTH//8] + identity = RNS.Identity(create_keys=False) + identity.load_public_key(public_key) + + if identity.validate(signature, signed_data): + self.__remote_identity = identity + if self.callbacks.remote_identified != None: + self.callbacks.remote_identified(self.__remote_identity) + elif packet.context == RNS.Packet.LRRTT: if not self.initiator: self.rtt_packet(packet) @@ -574,6 +617,15 @@ class Link: """ self.callbacks.resource_concluded = callback + def set_remote_identified_callback(self, callback): + """ + Registers a function to be called when an initiating peer has + identified over this link. + + :param callback: A function or method with the signature *callback(identity)* to be called. + """ + self.callbacks.remote_identified = callback + def resource_concluded(self, resource): if resource in self.incoming_resources: self.incoming_resources.remove(resource) diff --git a/RNS/Packet.py b/RNS/Packet.py index faf2235..4e9f70d 100755 --- a/RNS/Packet.py +++ b/RNS/Packet.py @@ -56,7 +56,8 @@ class Packet: PATH_RESPONSE = 0x0B # Packet is a response to a path request COMMAND = 0x0C # Packet is a command COMMAND_STATUS = 0x0D # Packet is a status of an executed command - KEEPALIVE = 0xFB # Packet is a keepalive packet + KEEPALIVE = 0xFA # Packet is a keepalive packet + LINKIDENTIFY = 0xFB # Packet is a link peer identification proof LINKCLOSE = 0xFC # Packet is a link close message LINKPROOF = 0xFD # Packet is a link packet proof LRRTT = 0xFE # Packet is a link request round-trip time measurement diff --git a/RNS/Transport.py b/RNS/Transport.py index c5cb665..d748faf 100755 --- a/RNS/Transport.py +++ b/RNS/Transport.py @@ -49,6 +49,8 @@ class Transport: receipts = [] # Receipts of all outgoing packets for proof processing # TODO: "destination_table" should really be renamed to "path_table" + # Notes on memory usage: 1 megabyte of memory can store approximately + # 55.100 path table entries or approximately 22.300 link table entries. announce_table = {} # A table for storing announces currently waiting to be retransmitted destination_table = {} # A lookup table containing the next hop to a given destination reverse_table = {} # A lookup table for storing packet hashes used to return proofs and replies diff --git a/RNS/__init__.py b/RNS/__init__.py index 72796fa..df74aa7 100755 --- a/RNS/__init__.py +++ b/RNS/__init__.py @@ -5,6 +5,8 @@ import time import random import threading +from ._version import __version__ + from .Reticulum import Reticulum from .Identity import Identity from .Link import Link @@ -60,6 +62,9 @@ def loglevelname(level): return "Unknown" +def version(): + return __version__ + def log(msg, level=3, _override_destination = False): global _always_override_destination @@ -68,7 +73,7 @@ def log(msg, level=3, _override_destination = False): logstring = "["+time.strftime(logtimefmt)+"] ["+loglevelname(level)+"] "+msg logging_lock.acquire() - if (logdest == LOG_STDOUT or _always_override_destination): + if (logdest == LOG_STDOUT or _always_override_destination or _override_destination): print(logstring) logging_lock.release() diff --git a/RNS/_version.py b/RNS/_version.py new file mode 100644 index 0000000..984fc57 --- /dev/null +++ b/RNS/_version.py @@ -0,0 +1 @@ +__version__ = "0.2.2" \ No newline at end of file diff --git a/setup.py b/setup.py index bd6f64f..a4a1deb 100644 --- a/setup.py +++ b/setup.py @@ -1,11 +1,13 @@ import setuptools +exec(open("RNS/_version.py", "r").read()) + with open("README.md", "r") as fh: long_description = fh.read() setuptools.setup( name="rns", - version="0.2.1", + version=__version__, author="Mark Qvist", author_email="mark@unsigned.io", description="Self-configuring, encrypted and resilient mesh networking stack for LoRa, packet radio, WiFi and everything in between",