Expanded and documented ratchet API

This commit is contained in:
Mark Qvist 2024-09-05 15:02:22 +02:00
parent 320704f812
commit 9ef10a7b3e
3 changed files with 189 additions and 70 deletions

View File

@ -72,7 +72,16 @@ class Destination:
directions = [IN, OUT] directions = [IN, OUT]
PR_TAG_WINDOW = 30 PR_TAG_WINDOW = 30
RATCHET_COUNT = 512
RATCHET_COUNT = 512
"""
The default number of generated ratchet keys a destination will retain, if it has ratchets enabled.
"""
RATCHET_INTERVAL = 30*60
"""
The minimum interval between rotating ratchet keys, in seconds.
"""
@staticmethod @staticmethod
def expand_name(identity, app_name, *aspects): def expand_name(identity, app_name, *aspects):
@ -142,6 +151,10 @@ class Destination:
self.proof_strategy = Destination.PROVE_NONE self.proof_strategy = Destination.PROVE_NONE
self.ratchets = None self.ratchets = None
self.ratchets_path = None self.ratchets_path = None
self.ratchet_interval = Destination.RATCHET_INTERVAL
self.retained_ratchets = Destination.RATCHET_COUNT
self.latest_ratchet_time = None
self.__enforce_ratchets = False
self.mtu = 0 self.mtu = 0
self.path_responses = {} self.path_responses = {}
@ -175,36 +188,12 @@ class Destination:
""" """
return "<"+self.name+"/"+self.hexhash+">" return "<"+self.name+"/"+self.hexhash+">"
def enable_ratchets(self, ratchets_path): def _clean_ratchets(self):
if ratchets_path != None: if self.ratchets != None:
if os.path.isfile(ratchets_path): if len (self.ratchets) > self.retained_ratchets:
try: self.ratchets = self.ratchets[:Destination.RATCHET_COUNT]
ratchets_file = open(ratchets_path, "rb")
persisted_data = umsgpack.unpackb(ratchets_file.read())
if "signature" in persisted_data and "ratchets" in persisted_data:
if self.identity.validate(persisted_data["signature"], persisted_data["ratchets"]):
self.ratchets = umsgpack.unpackb(persisted_data["ratchets"])
self.ratchets_path = ratchets_path
else:
raise KeyError("Invalid ratchet file signature")
except Exception as e: def _persist_ratchets(self):
self.ratchets = None
self.ratchets_path = None
raise OSError("Could not read ratchet file contents for "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
else:
RNS.log("No existing ratchet data found, initialising new ratchet file for "+str(self), RNS.LOG_DEBUG)
self.ratchets = []
self.ratchets_path = ratchets_path
self.persist_ratchets()
RNS.log("Ratchets enabled on "+str(self), RNS.LOG_DEBUG) # TODO: Remove
return True
else:
raise ValueError("No ratchet file path specified for "+str(self))
def persist_ratchets(self):
try: try:
packed_ratchets = umsgpack.packb(self.ratchets) packed_ratchets = umsgpack.packb(self.ratchets)
persisted_data = {"signature": self.sign(packed_ratchets), "ratchets": packed_ratchets} persisted_data = {"signature": self.sign(packed_ratchets), "ratchets": packed_ratchets}
@ -218,15 +207,20 @@ class Destination:
def rotate_ratchets(self): def rotate_ratchets(self):
if self.ratchets != None: if self.ratchets != None:
RNS.log("Rotating ratchets for "+str(self), RNS.LOG_DEBUG) # TODO: Remove now = time.time()
new_ratchet = RNS.Identity._generate_ratchet() if now > self.latest_ratchet_time+self.ratchet_interval:
self.ratchets.insert(0, new_ratchet) RNS.log("Rotating ratchets for "+str(self), RNS.LOG_DEBUG)
if len (self.ratchets) > Destination.RATCHET_COUNT: new_ratchet = RNS.Identity._generate_ratchet()
self.ratchets = self.ratchets[:Destination.RATCHET_COUNT] self.ratchets.insert(0, new_ratchet)
self.persist_ratchets() self.latest_ratchet_time = now
self._clean_ratchets()
self._persist_ratchets()
return True
else: else:
raise SystemError("Cannot rotate ratchet on "+str(self)+", ratchets are not enabled") raise SystemError("Cannot rotate ratchet on "+str(self)+", ratchets are not enabled")
return False
def announce(self, app_data=None, path_response=False, attached_interface=None, tag=None, send=True): def announce(self, app_data=None, path_response=False, attached_interface=None, tag=None, send=True):
""" """
Creates an announce packet for this destination and broadcasts it on all Creates an announce packet for this destination and broadcasts it on all
@ -272,8 +266,8 @@ class Destination:
self.rotate_ratchets() self.rotate_ratchets()
ratchet = RNS.Identity._ratchet_public_bytes(self.ratchets[0]) ratchet = RNS.Identity._ratchet_public_bytes(self.ratchets[0])
# TODO: Remove debug output # TODO: Remove at some point
RNS.log(f"Including ratchet {RNS.prettyhexrep(RNS.Identity.truncated_hash(ratchet))} in announce", RNS.LOG_DEBUG) RNS.log(f"Including ratchet {RNS.prettyhexrep(RNS.Identity.truncated_hash(ratchet))} in announce", RNS.LOG_EXTREME)
if app_data == None and self.default_app_data != None: if app_data == None and self.default_app_data != None:
if isinstance(self.default_app_data, bytes): if isinstance(self.default_app_data, bytes):
@ -366,7 +360,6 @@ class Destination:
else: else:
self.proof_strategy = proof_strategy self.proof_strategy = proof_strategy
def register_request_handler(self, path, response_generator = None, allow = ALLOW_NONE, allowed_list = None): def register_request_handler(self, path, response_generator = None, allow = ALLOW_NONE, allowed_list = None):
""" """
Registers a request handler. Registers a request handler.
@ -388,7 +381,6 @@ class Destination:
request_handler = [path, response_generator, allow, allowed_list] request_handler = [path, response_generator, allow, allowed_list]
self.request_handlers[path_hash] = request_handler self.request_handlers[path_hash] = request_handler
def deregister_request_handler(self, path): def deregister_request_handler(self, path):
""" """
Deregisters a request handler. Deregisters a request handler.
@ -403,8 +395,6 @@ class Destination:
else: else:
return False return False
def receive(self, packet): def receive(self, packet):
if packet.packet_type == RNS.Packet.LINKREQUEST: if packet.packet_type == RNS.Packet.LINKREQUEST:
plaintext = packet.data plaintext = packet.data
@ -419,13 +409,99 @@ class Destination:
except Exception as e: except Exception as e:
RNS.log("Error while executing receive callback from "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR) RNS.log("Error while executing receive callback from "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
def incoming_link_request(self, data, packet): def incoming_link_request(self, data, packet):
if self.accept_link_requests: if self.accept_link_requests:
link = RNS.Link.validate_request(self, data, packet) link = RNS.Link.validate_request(self, data, packet)
if link != None: if link != None:
self.links.append(link) self.links.append(link)
def enable_ratchets(self, ratchets_path):
"""
Enables ratchets on the destination. When ratchets are enabled, Reticulum will automatically rotate
the keys used to encrypt packets to this destination, and include the latest ratchet key in announces.
Enabling ratchets on a destination will provide forward secrecy for packets sent to that destination,
even when sent outside a ``Link``. The normal Reticulum ``Link`` establishment procedure already performs
its own ephemeral key exchange for each link establishment, which means that ratchets are not necessary
to provide forward secrecy for links.
Enabling ratchets will have a small impact on announce size, adding 32 bytes to every sent announce.
:param ratchets_path: The path to a file to store ratchet data in.
:returns: True if the operation succeeded, otherwise False.
"""
if ratchets_path != None:
self.latest_ratchet_time = 0
if os.path.isfile(ratchets_path):
try:
ratchets_file = open(ratchets_path, "rb")
persisted_data = umsgpack.unpackb(ratchets_file.read())
if "signature" in persisted_data and "ratchets" in persisted_data:
if self.identity.validate(persisted_data["signature"], persisted_data["ratchets"]):
self.ratchets = umsgpack.unpackb(persisted_data["ratchets"])
self.ratchets_path = ratchets_path
else:
raise KeyError("Invalid ratchet file signature")
except Exception as e:
self.ratchets = None
self.ratchets_path = None
raise OSError("Could not read ratchet file contents for "+str(self)+". The contained exception was: "+str(e), RNS.LOG_ERROR)
else:
RNS.log("No existing ratchet data found, initialising new ratchet file for "+str(self), RNS.LOG_DEBUG)
self.ratchets = []
self.ratchets_path = ratchets_path
self._persist_ratchets()
# TODO: Remove at some point
RNS.log("Ratchets enabled on "+str(self), RNS.LOG_DEBUG)
return True
else:
raise ValueError("No ratchet file path specified for "+str(self))
def enforce_ratchets(self):
"""
When ratchet enforcement is enabled, this destination will never accept packets that use its
base Identity key for encryption, but only accept packets encrypted with one of the retained
ratchet keys.
"""
if self.ratchets != None:
self.__enforce_ratchets = True
RNS.log("Ratchets enforced on "+str(self), RNS.LOG_DEBUG)
return True
else:
return False
def set_retained_ratchets(self, retained_ratchets):
"""
Sets the number of previously generated ratchet keys this destination will retain,
and try to use when decrypting incoming packets. Defaults to ``Destination.RATCHET_COUNT``.
:param retained_ratchets: The number of generated ratchets to retain.
:returns: True if the operation succeeded, False if not.
"""
if isinstance(retained_ratchets, int) and retained_ratchets > 0:
self.retained_ratchets = retained_ratchets
self._clean_ratchets()
return True
else:
return False
def set_ratchet_interval(self, interval):
"""
Sets the minimum interval in seconds between ratchet key rotation.
Defaults to ``Destination.RATCHET_INTERVAL``.
:param interval: The minimum interval in seconds.
:returns: True if the operation succeeded, False if not.
"""
if isinstance(interval, int) and interval > 0:
self.ratchet_interval = interval
return True
else:
return False
def create_keys(self): def create_keys(self):
""" """
For a ``RNS.Destination.GROUP`` type destination, creates a new symmetric key. For a ``RNS.Destination.GROUP`` type destination, creates a new symmetric key.
@ -442,7 +518,6 @@ class Destination:
self.prv_bytes = Fernet.generate_key() self.prv_bytes = Fernet.generate_key()
self.prv = Fernet(self.prv_bytes) self.prv = Fernet(self.prv_bytes)
def get_private_key(self): def get_private_key(self):
""" """
For a ``RNS.Destination.GROUP`` type destination, returns the symmetric private key. For a ``RNS.Destination.GROUP`` type destination, returns the symmetric private key.
@ -456,7 +531,6 @@ class Destination:
else: else:
return self.prv_bytes return self.prv_bytes
def load_private_key(self, key): def load_private_key(self, key):
""" """
For a ``RNS.Destination.GROUP`` type destination, loads a symmetric private key. For a ``RNS.Destination.GROUP`` type destination, loads a symmetric private key.
@ -480,7 +554,6 @@ class Destination:
else: else:
raise TypeError("A single destination holds keys through an Identity instance") raise TypeError("A single destination holds keys through an Identity instance")
def encrypt(self, plaintext): def encrypt(self, plaintext):
""" """
Encrypts information for ``RNS.Destination.SINGLE`` or ``RNS.Destination.GROUP`` type destination. Encrypts information for ``RNS.Destination.SINGLE`` or ``RNS.Destination.GROUP`` type destination.
@ -504,8 +577,6 @@ class Destination:
else: else:
raise ValueError("No private key held by GROUP destination. Did you create or load one?") raise ValueError("No private key held by GROUP destination. Did you create or load one?")
def decrypt(self, ciphertext): def decrypt(self, ciphertext):
""" """
Decrypts information for ``RNS.Destination.SINGLE`` or ``RNS.Destination.GROUP`` type destination. Decrypts information for ``RNS.Destination.SINGLE`` or ``RNS.Destination.GROUP`` type destination.
@ -517,7 +588,7 @@ class Destination:
return ciphertext return ciphertext
if self.type == Destination.SINGLE and self.identity != None: if self.type == Destination.SINGLE and self.identity != None:
return self.identity.decrypt(ciphertext, ratchets=self.ratchets) return self.identity.decrypt(ciphertext, ratchets=self.ratchets, enforce_ratchets=self.__enforce_ratchets)
if self.type == Destination.GROUP: if self.type == Destination.GROUP:
if hasattr(self, "prv") and self.prv != None: if hasattr(self, "prv") and self.prv != None:
@ -529,7 +600,6 @@ class Destination:
else: else:
raise ValueError("No private key held by GROUP destination. Did you create or load one?") raise ValueError("No private key held by GROUP destination. Did you create or load one?")
def sign(self, message): def sign(self, message):
""" """
Signs information for ``RNS.Destination.SINGLE`` type destination. Signs information for ``RNS.Destination.SINGLE`` type destination.

View File

@ -26,6 +26,7 @@ import RNS
import time import time
import atexit import atexit
import hashlib import hashlib
import threading
from .vendor import umsgpack as umsgpack from .vendor import umsgpack as umsgpack
@ -49,11 +50,20 @@ class Identity:
KEYSIZE = 256*2 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. X.25519 key size in bits. A complete key is the concatenation of a 256 bit encryption key, and a 256 bit signing key.
""" """
RATCHETSIZE = 256 RATCHETSIZE = 256
"""
X.25519 ratchet key size in bits.
"""
RATCHET_EXPIRY = 60*60*24*30 RATCHET_EXPIRY = 60*60*24*30
"""
The expiry time for received ratchets in seconds, defaults to 30 days. Reticulum will always use the most recently
announced ratchet, and remember it for up to ``RATCHET_EXPIRY`` since receiving it, after which it will be discarded.
If a newer ratchet is announced in the meantime, it will be replace the already known ratchet.
"""
# Non-configurable constants # Non-configurable constants
FERNET_OVERHEAD = RNS.Cryptography.Fernet.FERNET_OVERHEAD FERNET_OVERHEAD = RNS.Cryptography.Fernet.FERNET_OVERHEAD
@ -72,6 +82,8 @@ class Identity:
known_destinations = {} known_destinations = {}
known_ratchets = {} known_ratchets = {}
ratchet_persist_lock = threading.Lock()
@staticmethod @staticmethod
def remember(packet_hash, destination_hash, public_key, app_data = None): def remember(packet_hash, destination_hash, public_key, app_data = None):
if len(public_key) != Identity.KEYSIZE//8: if len(public_key) != Identity.KEYSIZE//8:
@ -237,33 +249,64 @@ class Identity:
@staticmethod @staticmethod
def _remember_ratchet(destination_hash, ratchet): def _remember_ratchet(destination_hash, ratchet):
RNS.log(f"Remembering ratchet {RNS.prettyhexrep(Identity.truncated_hash(ratchet))} for {RNS.prettyhexrep(destination_hash)}", RNS.LOG_DEBUG) # TODO: Remove # TODO: Remove at some point
RNS.log(f"Remembering ratchet {RNS.prettyhexrep(Identity.truncated_hash(ratchet))} for {RNS.prettyhexrep(destination_hash)}", RNS.LOG_EXTREME)
try: try:
Identity.known_ratchets[destination_hash] = ratchet 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 RNS.Transport.owner.is_connected_to_shared_instance:
def persist_job():
with Identity.ratchet_persist_lock:
hexhash = RNS.hexrep(destination_hash, delimit=False)
ratchet_data = {"ratchet": ratchet, "received": time.time()}
if not os.path.isdir(ratchetdir): ratchetdir = RNS.Reticulum.storagepath+"/ratchets"
os.makedirs(ratchetdir)
outpath = f"{ratchetdir}/{hexhash}.out" if not os.path.isdir(ratchetdir):
finalpath = f"{ratchetdir}/{hexhash}" os.makedirs(ratchetdir)
ratchet_file = open(outpath, "wb")
ratchet_file.write(umsgpack.packb(ratchet_data)) outpath = f"{ratchetdir}/{hexhash}.out"
ratchet_file.close() finalpath = f"{ratchetdir}/{hexhash}"
os.rename(outpath, finalpath) ratchet_file = open(outpath, "wb")
ratchet_file.write(umsgpack.packb(ratchet_data))
ratchet_file.close()
os.rename(outpath, finalpath)
threading.Thread(target=persist_job, daemon=True).start()
except Exception as e: except Exception as e:
RNS.log(f"Could not persist ratchet for {RNS.prettyhexrep(destination_hash)} to storage.", RNS.LOG_ERROR) 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.log(f"The contained exception was: {e}")
RNS.trace_exception(e) RNS.trace_exception(e)
@staticmethod
def _clean_ratchets():
RNS.log("Cleaning ratchets...", RNS.LOG_DEBUG)
try:
now = time.time()
ratchetdir = RNS.Reticulum.storagepath+"/ratchets"
for filename in os.listdir(ratchetdir):
try:
expired = False
with open(f"{ratchetdir}/{filename}", "rb") as rf:
ratchet_data = umsgpack.unpackb(rf.read())
if now > ratchet_data["received"]+Identity.RATCHET_EXPIRY:
expired = True
if expired:
os.unlink(f"{ratchetdir}/{filename}")
except Exception as e:
RNS.log(f"An error occurred while cleaning ratchets, in the processing of {ratchetdir}/{filename}.", RNS.LOG_ERROR)
RNS.log(f"The contained exception was: {e}", RNS.LOG_ERROR)
except Exception as e:
RNS.log(f"An error occurred while cleaning ratchets. The contained exception was: {e}", RNS.LOG_ERROR)
@staticmethod @staticmethod
def get_ratchet(destination_hash): def get_ratchet(destination_hash):
if not destination_hash in Identity.known_ratchets: 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" ratchetdir = RNS.Reticulum.storagepath+"/ratchets"
hexhash = RNS.hexrep(destination_hash, delimit=False) hexhash = RNS.hexrep(destination_hash, delimit=False)
ratchet_path = f"{ratchetdir}/hexhash" ratchet_path = f"{ratchetdir}/hexhash"
@ -284,6 +327,7 @@ class Identity:
if destination_hash in Identity.known_ratchets: if destination_hash in Identity.known_ratchets:
return Identity.known_ratchets[destination_hash] return Identity.known_ratchets[destination_hash]
else: else:
RNS.log(f"Could not load ratchet for {RNS.prettyhexrep(destination_hash)}", RNS.LOG_DEBUG)
return None return None
@staticmethod @staticmethod
@ -572,7 +616,8 @@ class Identity:
ephemeral_pub_bytes = ephemeral_key.public_key().public_bytes() ephemeral_pub_bytes = ephemeral_key.public_key().public_bytes()
if ratchet != None: if ratchet != None:
RNS.log(f"Encrypting with ratchet {RNS.prettyhexrep(RNS.Identity.truncated_hash(ratchet))}", RNS.LOG_DEBUG) # TODO: Remove # TODO: Remove at some point
RNS.log(f"Encrypting with ratchet {RNS.prettyhexrep(RNS.Identity.truncated_hash(ratchet))}", RNS.LOG_EXTREME)
target_public_key = X25519PublicKey.from_public_bytes(ratchet) target_public_key = X25519PublicKey.from_public_bytes(ratchet)
else: else:
target_public_key = self.pub target_public_key = self.pub
@ -595,7 +640,7 @@ class Identity:
raise KeyError("Encryption failed because identity does not hold a public key") raise KeyError("Encryption failed because identity does not hold a public key")
def decrypt(self, ciphertext_token, ratchets=None): def decrypt(self, ciphertext_token, ratchets=None, enforce_ratchets=False):
""" """
Decrypts information for the identity. Decrypts information for the identity.
@ -626,14 +671,17 @@ class Identity:
fernet = Fernet(derived_key) fernet = Fernet(derived_key)
plaintext = fernet.decrypt(ciphertext) plaintext = fernet.decrypt(ciphertext)
# TODO: Remove # TODO: Remove at some point
RNS.log(f"Decrypted with ratchet {RNS.prettyhexrep(RNS.Identity.truncated_hash(ratchet_prv.public_key().public_bytes()))}", RNS.LOG_DEBUG) RNS.log(f"Decrypted with ratchet {RNS.prettyhexrep(RNS.Identity.truncated_hash(ratchet_prv.public_key().public_bytes()))}", RNS.LOG_EXTREME)
break break
except Exception as e: except Exception as e:
pass pass
# RNS.log("Decryption using this ratchet failed", RNS.LOG_DEBUG) # TODO: Remove
if enforce_ratchets and plaintext == None:
RNS.log("Decryption with ratchet enforcement by "+RNS.prettyhexrep(self.hash)+" failed. Dropping packet.", RNS.LOG_DEBUG)
return None
if plaintext == None: if plaintext == None:
shared_key = self.prv.exchange(peer_pub) shared_key = self.prv.exchange(peer_pub)

View File

@ -295,6 +295,7 @@ class Reticulum:
def __start_jobs(self): def __start_jobs(self):
if self.jobs_thread == None: if self.jobs_thread == None:
RNS.Identity._clean_ratchets()
self.jobs_thread = threading.Thread(target=self.__jobs) self.jobs_thread = threading.Thread(target=self.__jobs)
self.jobs_thread.daemon = True self.jobs_thread.daemon = True
self.jobs_thread.start() self.jobs_thread.start()