From a11f14e75fee0141fca2c4e4da9962a5cb046003 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 4 Sep 2024 17:37:18 +0200 Subject: [PATCH] Implemented ratchets --- RNS/Destination.py | 27 +++++--- RNS/Identity.py | 163 +++++++++++++++++++++++++++++++++++++-------- RNS/Link.py | 2 +- RNS/Packet.py | 19 ++++-- RNS/Reticulum.py | 2 +- RNS/Transport.py | 5 +- 6 files changed, 170 insertions(+), 48 deletions(-) diff --git a/RNS/Destination.py b/RNS/Destination.py index 736dcba..92e4735 100755 --- a/RNS/Destination.py +++ b/RNS/Destination.py @@ -1,6 +1,6 @@ # MIT License # -# Copyright (c) 2016-2023 Mark Qvist / unsigned.io and contributors +# Copyright (c) 2016-2024 Mark Qvist / unsigned.io and contributors # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -72,7 +72,7 @@ class Destination: directions = [IN, OUT] PR_TAG_WINDOW = 30 - RATCHET_COUNT = 128 + RATCHET_COUNT = 256 @staticmethod def expand_name(identity, app_name, *aspects): @@ -219,7 +219,7 @@ class Destination: def rotate_ratchets(self): if self.ratchets != None: RNS.log("Rotating ratchets for "+str(self), RNS.LOG_DEBUG) # TODO: Remove - new_ratchet = RNS.Identity.generate_ratchet() + new_ratchet = RNS.Identity._generate_ratchet() self.ratchets.insert(0, new_ratchet) if len (self.ratchets) > Destination.RATCHET_COUNT: self.ratchets = self.ratchets[:Destination.RATCHET_COUNT] @@ -241,6 +241,7 @@ class Destination: if self.direction != Destination.IN: raise TypeError("Only IN destination types can be announced") + ratchet = b"" now = time.time() stale_responses = [] for entry_tag in self.path_responses: @@ -269,8 +270,8 @@ class Destination: if self.ratchets != None: self.rotate_ratchets() - ratchet_pub = RNS.Identity.ratchet_public_bytes(self.ratchets[0]) - RNS.log(f"Including {len(ratchet_pub)*8}-bit ratchet {RNS.hexrep(ratchet_pub)} in announce", RNS.LOG_DEBUG) # TODO: Remove + ratchet = RNS.Identity._ratchet_public_bytes(self.ratchets[0]) + RNS.log(f"Including {len(ratchet)*8}-bit ratchet {RNS.hexrep(ratchet)} in announce", RNS.LOG_DEBUG) # TODO: Remove if app_data == None and self.default_app_data != None: if isinstance(self.default_app_data, bytes): @@ -280,13 +281,12 @@ class Destination: if isinstance(returned_app_data, bytes): app_data = returned_app_data - signed_data = self.hash+self.identity.get_public_key()+self.name_hash+random_hash + signed_data = self.hash+self.identity.get_public_key()+self.name_hash+random_hash+ratchet if app_data != None: signed_data += app_data signature = self.identity.sign(signed_data) - - announce_data = self.identity.get_public_key()+self.name_hash+random_hash+signature + announce_data = self.identity.get_public_key()+self.name_hash+random_hash+ratchet+signature if app_data != None: announce_data += app_data @@ -298,8 +298,13 @@ class Destination: else: announce_context = RNS.Packet.NONE - announce_packet = RNS.Packet(self, announce_data, RNS.Packet.ANNOUNCE, context = announce_context, attached_interface = attached_interface) + if ratchet: + context_flag = RNS.Packet.FLAG_SET + else: + context_flag = RNS.Packet.FLAG_UNSET + announce_packet = RNS.Packet(self, announce_data, RNS.Packet.ANNOUNCE, context = announce_context, + attached_interface = attached_interface, context_flag=context_flag) if send: announce_packet.send() else: @@ -485,7 +490,7 @@ class Destination: return plaintext if self.type == Destination.SINGLE and self.identity != None: - return self.identity.encrypt(plaintext) + return self.identity.encrypt(plaintext, ratchet=RNS.Identity.get_ratchet(self.hash)) if self.type == Destination.GROUP: if hasattr(self, "prv") and self.prv != None: @@ -510,7 +515,7 @@ class Destination: return ciphertext if self.type == Destination.SINGLE and self.identity != None: - return self.identity.decrypt(ciphertext) + return self.identity.decrypt(ciphertext, ratchets=self.ratchets) if self.type == Destination.GROUP: if hasattr(self, "prv") and self.prv != None: diff --git a/RNS/Identity.py b/RNS/Identity.py index 010c1fd..c778663 100644 --- a/RNS/Identity.py +++ b/RNS/Identity.py @@ -1,6 +1,6 @@ # MIT License # -# Copyright (c) 2016-2023 Mark Qvist / unsigned.io and contributors. +# Copyright (c) 2016-2024 Mark Qvist / unsigned.io and contributors. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -50,7 +50,10 @@ class Identity: KEYSIZE = 256*2 """ X25519 key size in bits. A complete key is the concatenation of a 256 bit encryption key, and a 256 bit signing key. - """ + """ + + RATCHETSIZE = 256 + RATCHET_EXPIRY = 60*60*24*30 # Non-configurable constants FERNET_OVERHEAD = RNS.Cryptography.Fernet.FERNET_OVERHEAD @@ -67,6 +70,7 @@ class Identity: # Storage known_destinations = {} + known_ratchets = {} @staticmethod def remember(packet_hash, destination_hash, public_key, app_data = None): @@ -222,29 +226,102 @@ class Identity: return Identity.truncated_hash(os.urandom(Identity.TRUNCATED_HASHLENGTH//8)) @staticmethod - def generate_ratchet(): + def _ratchet_public_bytes(ratchet): + return X25519PrivateKey.from_private_bytes(ratchet).public_key().public_bytes() + + @staticmethod + def _generate_ratchet(): ratchet_prv = X25519PrivateKey.generate() ratchet_pub = ratchet_prv.public_key() return ratchet_prv.private_bytes() @staticmethod - def ratchet_public_bytes(ratchet): - return X25519PrivateKey.from_private_bytes(ratchet).public_key().public_bytes() + def _remember_ratchet(destination_hash, ratchet): + RNS.log(f"Remembering ratchet {RNS.hexrep(ratchet)} for {RNS.prettyhexrep(destination_hash)}", RNS.LOG_DEBUG) # TODO: Remove + try: + Identity.known_ratchets[destination_hash] = ratchet + hexhash = RNS.hexrep(destination_hash, delimit=False) + ratchet_data = {"ratchet": ratchet, "received": time.time()} + + ratchetdir = RNS.Reticulum.storagepath+"/ratchets" + + if not os.path.isdir(ratchetdir): + os.makedirs(ratchetdir) + + outpath = f"{ratchetdir}/{hexhash}.out" + finalpath = f"{ratchetdir}/{hexhash}" + ratchet_file = open(outpath, "wb") + ratchet_file.write(umsgpack.packb(ratchet_data)) + ratchet_file.close() + os.rename(outpath, finalpath) + + except Exception as e: + RNS.log(f"Could not persist ratchet for {RNS.prettyhexrep(destination_hash)} to storage.", RNS.LOG_ERROR) + RNS.log(f"The contained exception was: {e}") + RNS.trace_exception(e) + + @staticmethod + def get_ratchet(destination_hash): + if not destination_hash in Identity.known_ratchets: + RNS.log(f"Trying to load ratchet for {RNS.prettyhexrep(destination_hash)} from storage") # TODO: Remove + ratchetdir = RNS.Reticulum.storagepath+"/ratchets" + hexhash = RNS.hexrep(destination_hash, delimit=False) + ratchet_path = f"{ratchetdir}/hexhash" + if os.path.isfile(ratchet_path): + try: + ratchet_file = open(ratchet_path, "rb") + ratchet_data = umsgpack.unpackb(ratchets_file.read()) + if time.time() < ratchet_data["received"]+Identity.RATCHET_EXPIRY and len(ratchet_data["ratchet"]) == Identity.RATCHETSIZE//8: + Identity.known_ratchets[destination_hash] = ratchet_data["ratchet"] + else: + return None + + except Exception as e: + RNS.log(f"An error occurred while loading ratchet data for {RNS.prettyhexrep(destination_hash)} from storage.", RNS.LOG_ERROR) + RNS.log(f"The contained exception was: {e}", RNS.LOG_ERROR) + return None + + if destination_hash in Identity.known_ratchets: + return Identity.known_ratchets[destination_hash] + else: + return None @staticmethod def validate_announce(packet, only_validate_signature=False): try: if packet.packet_type == RNS.Packet.ANNOUNCE: + keysize = Identity.KEYSIZE//8 + ratchetsize = Identity.RATCHETSIZE//8 + name_hash_len = Identity.NAME_HASH_LENGTH//8 + sig_len = Identity.SIGLENGTH//8 destination_hash = packet.destination_hash - public_key = packet.data[:Identity.KEYSIZE//8] - name_hash = packet.data[Identity.KEYSIZE//8:Identity.KEYSIZE//8+Identity.NAME_HASH_LENGTH//8] - random_hash = packet.data[Identity.KEYSIZE//8+Identity.NAME_HASH_LENGTH//8:Identity.KEYSIZE//8+Identity.NAME_HASH_LENGTH//8+10] - signature = packet.data[Identity.KEYSIZE//8+Identity.NAME_HASH_LENGTH//8+10:Identity.KEYSIZE//8+Identity.NAME_HASH_LENGTH//8+10+Identity.SIGLENGTH//8] - app_data = b"" - if len(packet.data) > Identity.KEYSIZE//8+Identity.NAME_HASH_LENGTH//8+10+Identity.SIGLENGTH//8: - app_data = packet.data[Identity.KEYSIZE//8+Identity.NAME_HASH_LENGTH//8+10+Identity.SIGLENGTH//8:] - signed_data = destination_hash+public_key+name_hash+random_hash+app_data + # Get public key bytes from announce + public_key = packet.data[:keysize] + + # If the packet context flag is set, + # this announce contains a new ratchet + if packet.context_flag == RNS.Packet.FLAG_SET: + name_hash = packet.data[keysize:keysize+name_hash_len ] + random_hash = packet.data[keysize+name_hash_len:keysize+name_hash_len+10] + ratchet = packet.data[keysize+name_hash_len+10:keysize+name_hash_len+10+ratchetsize] + signature = packet.data[keysize+name_hash_len+10+ratchetsize:keysize+name_hash_len+10+ratchetsize+sig_len] + app_data = b"" + if len(packet.data) > keysize+name_hash_len+10+sig_len+ratchetsize: + app_data = packet.data[keysize+name_hash_len+10+sig_len+ratchetsize:] + + # If the packet context flag is not set, + # this announce does not contain a ratchet + else: + ratchet = b"" + name_hash = packet.data[keysize:keysize+name_hash_len] + random_hash = packet.data[keysize+name_hash_len:keysize+name_hash_len+10] + signature = packet.data[keysize+name_hash_len+10:keysize+name_hash_len+10+sig_len] + app_data = b"" + if len(packet.data) > keysize+name_hash_len+10+sig_len: + app_data = packet.data[keysize+name_hash_len+10+sig_len:] + + signed_data = destination_hash+public_key+name_hash+random_hash+ratchet+app_data if not len(packet.data) > Identity.KEYSIZE//8+Identity.NAME_HASH_LENGTH//8+10+Identity.SIGLENGTH//8: app_data = None @@ -291,6 +368,9 @@ class Identity: else: RNS.log("Valid announce for "+RNS.prettyhexrep(destination_hash)+" "+str(packet.hops)+" hops away, received on "+str(packet.receiving_interface)+signal_str, RNS.LOG_EXTREME) + if ratchet: + Identity._remember_ratchet(destination_hash, ratchet) + return True else: @@ -479,7 +559,7 @@ class Identity: def get_context(self): return None - def encrypt(self, plaintext): + def encrypt(self, plaintext, ratchet=None): """ Encrypts information for the identity. @@ -491,7 +571,13 @@ class Identity: ephemeral_key = X25519PrivateKey.generate() ephemeral_pub_bytes = ephemeral_key.public_key().public_bytes() - shared_key = ephemeral_key.exchange(self.pub) + if ratchet != None: + RNS.log(f"Encrypting with ratchet {RNS.hexrep(ratchet)}", RNS.LOG_DEBUG) # TODO: Remove + target_public_key = X25519PublicKey.from_public_bytes(ratchet) + else: + target_public_key = self.pub + + shared_key = ephemeral_key.exchange(target_public_key) derived_key = RNS.Cryptography.hkdf( length=32, @@ -509,7 +595,7 @@ class Identity: raise KeyError("Encryption failed because identity does not hold a public key") - def decrypt(self, ciphertext_token): + def decrypt(self, ciphertext_token, ratchets=None): """ Decrypts information for the identity. @@ -523,19 +609,40 @@ class Identity: try: peer_pub_bytes = ciphertext_token[:Identity.KEYSIZE//8//2] peer_pub = X25519PublicKey.from_public_bytes(peer_pub_bytes) - - shared_key = self.prv.exchange(peer_pub) - - derived_key = RNS.Cryptography.hkdf( - length=32, - derive_from=shared_key, - salt=self.get_salt(), - context=self.get_context(), - ) - - fernet = Fernet(derived_key) ciphertext = ciphertext_token[Identity.KEYSIZE//8//2:] - plaintext = fernet.decrypt(ciphertext) + + if ratchets: + for ratchet in ratchets: + try: + ratchet_prv = X25519PrivateKey.from_private_bytes(ratchet) + shared_key = ratchet_prv.exchange(peer_pub) + derived_key = RNS.Cryptography.hkdf( + length=32, + derive_from=shared_key, + salt=self.get_salt(), + context=self.get_context(), + ) + + fernet = Fernet(derived_key) + plaintext = fernet.decrypt(ciphertext) + RNS.log(f"Decrypted with ratchet {RNS.hexrep(ratchet_prv.public_key().public_bytes())}", RNS.LOG_DEBUG) # TODO: Remove + break + + except Exception as e: + pass + # RNS.log("Decryption using this ratchet failed", RNS.LOG_DEBUG) # TODO: Remove + + if plaintext == None: + shared_key = self.prv.exchange(peer_pub) + derived_key = RNS.Cryptography.hkdf( + length=32, + derive_from=shared_key, + salt=self.get_salt(), + context=self.get_context(), + ) + + fernet = Fernet(derived_key) + plaintext = fernet.decrypt(ciphertext) except Exception as e: RNS.log("Decryption by "+RNS.prettyhexrep(self.hash)+" failed: "+str(e), RNS.LOG_DEBUG) diff --git a/RNS/Link.py b/RNS/Link.py index 4757017..95fdb12 100644 --- a/RNS/Link.py +++ b/RNS/Link.py @@ -1,6 +1,6 @@ # MIT License # -# Copyright (c) 2016-2023 Mark Qvist / unsigned.io and contributors. +# Copyright (c) 2016-2024 Mark Qvist / unsigned.io and contributors. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal diff --git a/RNS/Packet.py b/RNS/Packet.py index e47f05b..4246aca 100755 --- a/RNS/Packet.py +++ b/RNS/Packet.py @@ -1,6 +1,6 @@ # MIT License # -# Copyright (c) 2016-2023 Mark Qvist / unsigned.io and contributors. +# Copyright (c) 2016-2024 Mark Qvist / unsigned.io and contributors. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -83,6 +83,10 @@ class Packet: LRRTT = 0xFE # Packet is a link request round-trip time measurement LRPROOF = 0xFF # Packet is a link request proof + # Context flag values + FLAG_SET = 0x01 + FLAG_UNSET = 0x00 + # This is used to calculate allowable # payload sizes HEADER_MAXSIZE = RNS.Reticulum.HEADER_MAXSIZE @@ -102,7 +106,9 @@ class Packet: TIMEOUT_PER_HOP = RNS.Reticulum.DEFAULT_PER_HOP_TIMEOUT - def __init__(self, destination, data, packet_type = DATA, context = NONE, transport_type = RNS.Transport.BROADCAST, header_type = HEADER_1, transport_id = None, attached_interface = None, create_receipt = True): + def __init__(self, destination, data, packet_type = DATA, context = NONE, transport_type = RNS.Transport.BROADCAST, + header_type = HEADER_1, transport_id = None, attached_interface = None, create_receipt = True, context_flag=FLAG_UNSET): + if destination != None: if transport_type == None: transport_type = RNS.Transport.BROADCAST @@ -111,6 +117,7 @@ class Packet: self.packet_type = packet_type self.transport_type = transport_type self.context = context + self.context_flag = context_flag self.hops = 0; self.destination = destination @@ -142,9 +149,10 @@ class Packet: def get_packed_flags(self): if self.context == Packet.LRPROOF: - packed_flags = (self.header_type << 6) | (self.transport_type << 4) | (RNS.Destination.LINK << 2) | self.packet_type + packed_flags = (self.header_type << 6) | (self.context_flag << 5) | (self.transport_type << 4) | (RNS.Destination.LINK << 2) | self.packet_type else: - packed_flags = (self.header_type << 6) | (self.transport_type << 4) | (self.destination.type << 2) | self.packet_type + packed_flags = (self.header_type << 6) | (self.context_flag << 5) | (self.transport_type << 4) | (self.destination.type << 2) | self.packet_type + return packed_flags def pack(self): @@ -216,7 +224,8 @@ class Packet: self.hops = self.raw[1] self.header_type = (self.flags & 0b01000000) >> 6 - self.transport_type = (self.flags & 0b00110000) >> 4 + self.context_flag = (self.flags & 0b00100000) >> 5 + self.transport_type = (self.flags & 0b00010000) >> 4 self.destination_type = (self.flags & 0b00001100) >> 2 self.packet_type = (self.flags & 0b00000011) diff --git a/RNS/Reticulum.py b/RNS/Reticulum.py index a681a98..d28ca08 100755 --- a/RNS/Reticulum.py +++ b/RNS/Reticulum.py @@ -1,6 +1,6 @@ # MIT License # -# Copyright (c) 2016-2023 Mark Qvist / unsigned.io and contributors. +# Copyright (c) 2016-2024 Mark Qvist / unsigned.io and contributors. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal diff --git a/RNS/Transport.py b/RNS/Transport.py index f360460..a8d978b 100755 --- a/RNS/Transport.py +++ b/RNS/Transport.py @@ -1,6 +1,6 @@ # MIT License # -# Copyright (c) 2016-2023 Mark Qvist / unsigned.io and contributors. +# Copyright (c) 2016-2024 Mark Qvist / unsigned.io and contributors. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -403,7 +403,8 @@ class Transport: header_type = RNS.Packet.HEADER_2, transport_type = Transport.TRANSPORT, transport_id = Transport.identity.hash, - attached_interface = attached_interface + attached_interface = attached_interface, + context_flag = packet.context_flag, ) new_packet.hops = announce_entry[4]