Implemented ratchets

This commit is contained in:
Mark Qvist 2024-09-04 17:37:18 +02:00
parent c32086c6f1
commit a11f14e75f
6 changed files with 170 additions and 48 deletions

View File

@ -1,6 +1,6 @@
# MIT License # 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 # Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal # of this software and associated documentation files (the "Software"), to deal
@ -72,7 +72,7 @@ class Destination:
directions = [IN, OUT] directions = [IN, OUT]
PR_TAG_WINDOW = 30 PR_TAG_WINDOW = 30
RATCHET_COUNT = 128 RATCHET_COUNT = 256
@staticmethod @staticmethod
def expand_name(identity, app_name, *aspects): def expand_name(identity, app_name, *aspects):
@ -219,7 +219,7 @@ 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 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) self.ratchets.insert(0, new_ratchet)
if len (self.ratchets) > Destination.RATCHET_COUNT: if len (self.ratchets) > Destination.RATCHET_COUNT:
self.ratchets = self.ratchets[:Destination.RATCHET_COUNT] self.ratchets = self.ratchets[:Destination.RATCHET_COUNT]
@ -241,6 +241,7 @@ class Destination:
if self.direction != Destination.IN: if self.direction != Destination.IN:
raise TypeError("Only IN destination types can be announced") raise TypeError("Only IN destination types can be announced")
ratchet = b""
now = time.time() now = time.time()
stale_responses = [] stale_responses = []
for entry_tag in self.path_responses: for entry_tag in self.path_responses:
@ -269,8 +270,8 @@ class Destination:
if self.ratchets != None: if self.ratchets != None:
self.rotate_ratchets() self.rotate_ratchets()
ratchet_pub = RNS.Identity.ratchet_public_bytes(self.ratchets[0]) ratchet = 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 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 app_data == None and self.default_app_data != None:
if isinstance(self.default_app_data, bytes): if isinstance(self.default_app_data, bytes):
@ -280,13 +281,12 @@ class Destination:
if isinstance(returned_app_data, bytes): if isinstance(returned_app_data, bytes):
app_data = returned_app_data 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: if app_data != None:
signed_data += app_data signed_data += app_data
signature = self.identity.sign(signed_data) signature = self.identity.sign(signed_data)
announce_data = self.identity.get_public_key()+self.name_hash+random_hash+ratchet+signature
announce_data = self.identity.get_public_key()+self.name_hash+random_hash+signature
if app_data != None: if app_data != None:
announce_data += app_data announce_data += app_data
@ -298,8 +298,13 @@ class Destination:
else: else:
announce_context = RNS.Packet.NONE 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: if send:
announce_packet.send() announce_packet.send()
else: else:
@ -485,7 +490,7 @@ class Destination:
return plaintext return plaintext
if self.type == Destination.SINGLE and self.identity != None: 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 self.type == Destination.GROUP:
if hasattr(self, "prv") and self.prv != None: if hasattr(self, "prv") and self.prv != None:
@ -510,7 +515,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) return self.identity.decrypt(ciphertext, ratchets=self.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:

View File

@ -1,6 +1,6 @@
# MIT License # 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 # Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal # of this software and associated documentation files (the "Software"), to deal
@ -52,6 +52,9 @@ class Identity:
X25519 key size in bits. A complete key is the concatenation of a 256 bit encryption key, and a 256 bit signing key. 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 # Non-configurable constants
FERNET_OVERHEAD = RNS.Cryptography.Fernet.FERNET_OVERHEAD FERNET_OVERHEAD = RNS.Cryptography.Fernet.FERNET_OVERHEAD
AES128_BLOCKSIZE = 16 # In bytes AES128_BLOCKSIZE = 16 # In bytes
@ -67,6 +70,7 @@ class Identity:
# Storage # Storage
known_destinations = {} known_destinations = {}
known_ratchets = {}
@staticmethod @staticmethod
def remember(packet_hash, destination_hash, public_key, app_data = None): 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)) return Identity.truncated_hash(os.urandom(Identity.TRUNCATED_HASHLENGTH//8))
@staticmethod @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_prv = X25519PrivateKey.generate()
ratchet_pub = ratchet_prv.public_key() ratchet_pub = ratchet_prv.public_key()
return ratchet_prv.private_bytes() return ratchet_prv.private_bytes()
@staticmethod @staticmethod
def ratchet_public_bytes(ratchet): def _remember_ratchet(destination_hash, ratchet):
return X25519PrivateKey.from_private_bytes(ratchet).public_key().public_bytes() 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 @staticmethod
def validate_announce(packet, only_validate_signature=False): def validate_announce(packet, only_validate_signature=False):
try: try:
if packet.packet_type == RNS.Packet.ANNOUNCE: 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 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: if not len(packet.data) > Identity.KEYSIZE//8+Identity.NAME_HASH_LENGTH//8+10+Identity.SIGLENGTH//8:
app_data = None app_data = None
@ -291,6 +368,9 @@ class Identity:
else: 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) 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 return True
else: else:
@ -479,7 +559,7 @@ class Identity:
def get_context(self): def get_context(self):
return None return None
def encrypt(self, plaintext): def encrypt(self, plaintext, ratchet=None):
""" """
Encrypts information for the identity. Encrypts information for the identity.
@ -491,7 +571,13 @@ class Identity:
ephemeral_key = X25519PrivateKey.generate() ephemeral_key = X25519PrivateKey.generate()
ephemeral_pub_bytes = ephemeral_key.public_key().public_bytes() 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( derived_key = RNS.Cryptography.hkdf(
length=32, length=32,
@ -509,7 +595,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): def decrypt(self, ciphertext_token, ratchets=None):
""" """
Decrypts information for the identity. Decrypts information for the identity.
@ -523,19 +609,40 @@ class Identity:
try: try:
peer_pub_bytes = ciphertext_token[:Identity.KEYSIZE//8//2] peer_pub_bytes = ciphertext_token[:Identity.KEYSIZE//8//2]
peer_pub = X25519PublicKey.from_public_bytes(peer_pub_bytes) 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:] 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: except Exception as e:
RNS.log("Decryption by "+RNS.prettyhexrep(self.hash)+" failed: "+str(e), RNS.LOG_DEBUG) RNS.log("Decryption by "+RNS.prettyhexrep(self.hash)+" failed: "+str(e), RNS.LOG_DEBUG)

View File

@ -1,6 +1,6 @@
# MIT License # 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 # Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal # of this software and associated documentation files (the "Software"), to deal

View File

@ -1,6 +1,6 @@
# MIT License # 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 # Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal # 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 LRRTT = 0xFE # Packet is a link request round-trip time measurement
LRPROOF = 0xFF # Packet is a link request proof LRPROOF = 0xFF # Packet is a link request proof
# Context flag values
FLAG_SET = 0x01
FLAG_UNSET = 0x00
# This is used to calculate allowable # This is used to calculate allowable
# payload sizes # payload sizes
HEADER_MAXSIZE = RNS.Reticulum.HEADER_MAXSIZE HEADER_MAXSIZE = RNS.Reticulum.HEADER_MAXSIZE
@ -102,7 +106,9 @@ class Packet:
TIMEOUT_PER_HOP = RNS.Reticulum.DEFAULT_PER_HOP_TIMEOUT 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 destination != None:
if transport_type == None: if transport_type == None:
transport_type = RNS.Transport.BROADCAST transport_type = RNS.Transport.BROADCAST
@ -111,6 +117,7 @@ class Packet:
self.packet_type = packet_type self.packet_type = packet_type
self.transport_type = transport_type self.transport_type = transport_type
self.context = context self.context = context
self.context_flag = context_flag
self.hops = 0; self.hops = 0;
self.destination = destination self.destination = destination
@ -142,9 +149,10 @@ class Packet:
def get_packed_flags(self): def get_packed_flags(self):
if self.context == Packet.LRPROOF: 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: 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 return packed_flags
def pack(self): def pack(self):
@ -216,7 +224,8 @@ class Packet:
self.hops = self.raw[1] self.hops = self.raw[1]
self.header_type = (self.flags & 0b01000000) >> 6 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.destination_type = (self.flags & 0b00001100) >> 2
self.packet_type = (self.flags & 0b00000011) self.packet_type = (self.flags & 0b00000011)

View File

@ -1,6 +1,6 @@
# MIT License # 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 # Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal # of this software and associated documentation files (the "Software"), to deal

View File

@ -1,6 +1,6 @@
# MIT License # 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 # Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal # of this software and associated documentation files (the "Software"), to deal
@ -403,7 +403,8 @@ class Transport:
header_type = RNS.Packet.HEADER_2, header_type = RNS.Packet.HEADER_2,
transport_type = Transport.TRANSPORT, transport_type = Transport.TRANSPORT,
transport_id = Transport.identity.hash, transport_id = Transport.identity.hash,
attached_interface = attached_interface attached_interface = attached_interface,
context_flag = packet.context_flag,
) )
new_packet.hops = announce_entry[4] new_packet.hops = announce_entry[4]