Compare commits

...

36 Commits

Author SHA1 Message Date
jacobeva
f4b882a042
Merge 9d744e2317 into 7b7ebbec90 2024-09-09 09:55:46 -04:00
Mark Qvist
7b7ebbec90 Updated roadmap 2024-09-09 15:13:21 +02:00
Mark Qvist
8b3523dee0 Updated changelog 2024-09-09 15:09:42 +02:00
Mark Qvist
2901ed2bae Updated changelog 2024-09-09 15:09:07 +02:00
Mark Qvist
34010c94d1 Updated manual 2024-09-09 15:08:46 +02:00
Mark Qvist
a4b5248a4c Cleanup 2024-09-09 14:48:58 +02:00
Mark Qvist
75272d77a5 Cleanup 2024-09-09 14:47:28 +02:00
Mark Qvist
d4ad4589dd Cleanup 2024-09-09 14:46:58 +02:00
Mark Qvist
8d45ad36eb Cleanup 2024-09-09 14:46:32 +02:00
Mark Qvist
2a0d411869 Cleanup 2024-09-09 14:45:08 +02:00
Mark Qvist
b9421347ef Cleanup 2024-09-09 14:43:50 +02:00
markqvist
ffec78d49a
Merge pull request #544 from deavmi/deavmi-patch-1
Add a pinch of CI/CD (no CD yet)
2024-09-09 14:42:30 +02:00
Mark Qvist
356ae378f9 Cleanup 2024-09-09 14:32:07 +02:00
Mark Qvist
28e3919dbd T-Echo product and model codes 2024-09-09 14:30:06 +02:00
markqvist
58a19610c4
Merge pull request #541 from jeremybox/t-echo
Add support for TECHO device
2024-09-09 14:18:15 +02:00
Mark Qvist
50b1eae380 File move fix for windows 2024-09-09 02:11:46 +02:00
Mark Qvist
c119ef4273 Standardised ratchet id getter 2024-09-08 20:33:35 +02:00
Mark Qvist
b506ca94d0 Updated documentation and manual 2024-09-08 17:56:02 +02:00
Mark Qvist
a072a5b074 Added automatic ratchet reload if required ratchet is unavailable 2024-09-08 17:48:25 +02:00
Mark Qvist
3a580e74de Make ratchet IDs available to applications 2024-09-08 14:55:07 +02:00
jeremy
9a20a3929a correct t-echo model 2024-09-07 19:17:06 -04:00
Mark Qvist
fe054fd03c Added destination ratchet ID getter to API 2024-09-07 22:32:03 +02:00
Tristan Brice Velloza Kildaire
3eb8d92028 Rename 2024-09-04 23:59:03 +02:00
Tristan Brice Velloza Kildaire
ef3baf2cd9 Add bade
(Will work once active on mark's repo)
2024-09-04 23:58:16 +02:00
Tristan Brice Velloza Kildaire
f2f936d846 Clean up testing 2024-09-04 23:56:55 +02:00
Tristan Brice Velloza Kildaire
6599e210de Fixed up test 2024-09-04 23:56:01 +02:00
jacob.eva
9d744e2317
Allow for display use by master on NRF52 on Android 2024-09-04 11:54:32 +01:00
jacob.eva
d64064691a
Allow for use of display by master on NRF52 2024-09-04 11:52:41 +01:00
jeremy
b4ac3df2d0 remove t-echo menu items 2024-09-03 17:24:11 -04:00
jeremy
8193f3621c remove symlink 2024-09-03 17:17:17 -04:00
jeremybox
5166596375
Update RNodeInterface.py
reverts extra debugging message detail
2024-09-03 17:14:07 -04:00
jeremy
625db2622d Pushing changes to branch 2024-09-03 17:09:59 -04:00
Tristan B. Velloza Kildaire
a8bc468e21
Update python-app.yml 2024-09-03 18:53:11 +02:00
Tristan B. Velloza Kildaire
95c4269869
Create python-app.yml 2024-09-03 18:52:10 +02:00
jeremy
65a40aefb6 trying to get techo working 2024-09-03 01:57:07 -04:00
jeremy
a840bd4aaf changes needed to support the t-echo device 2024-08-31 23:39:36 -04:00
20 changed files with 246 additions and 69 deletions

28
.github/workflows/python-app.yml vendored Normal file
View File

@ -0,0 +1,28 @@
# This workflow will install Python dependencies, run tests and lint with a single version of Python
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
name: Test suite
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.10
uses: actions/setup-python@v3
with:
python-version: "3.10"
- name: Test
run: |
make test

View File

@ -1,6 +1,31 @@
### 2024-09-09: RNS β 0.7.7
This release adds support for automatic encryption key ratcheting for all packets, not just those sent over Reticulum links. In practical terms, this adds forward secrecy to packets sent with the raw `Packet` API.
In this release, the ratchets feature must be enabled on a per-destination basis by calling the `enable_ratchets` method on the relevant destination. In a future release, ratchets may become the default option, but for backwards-compatibility, it is currently optional. For more information, read the API documentation.
**Please note!** Versions of RNS prior to `0.7.7` will not be able to pass announces for destinations with ratchets enabled! If you use applications that can use ratchets (for example, LXMF version `0.5.0` and up), it is important that you update all transport instances on your network to `0.7.7`.
Thanks to @deavmi, @faragher, @jacobeva, @jeremy and @jeremybox for contributing to this release!
**Changes**
- Added key ratchet rotation and signalling
- Added ratchet API to documentation
- Added initial support for flashing T-Echo devices to `rnodeconf`
- Added remote management config options to example config
- Added automtic integration tests to source repository
- Fixed a regression that caused RNS not to work on Python versions lower than 3.10
- Fixed missing `establishment_rate` property init on Link objects
**Release Hashes**
```
0a3ab6dc82567a19adabe737358daee3002b60beda8ac0bf228f2a0c134ff6d8 rns-0.7.7-py3-none-any.whl
89b33fe9ab923139d3f5d43726d92817642be05a8c9d328c3becfc3c409e4b4b rnspure-0.7.7-py3-none-any.whl
```
### 2024-05-18: RNS β 0.7.6 ### 2024-05-18: RNS β 0.7.6
This release add support for RNodes with multiple radio transceivers, courtesy of @jacobeva. It also brings a number of functionality and performance improvements, and fixes several bugs. This release adds support for RNodes with multiple radio transceivers, courtesy of @jacobeva. It also brings a number of functionality and performance improvements, and fixes several bugs.
Thanks to @jacobeva, @faragher, @nathmo, @jschulthess and @liamcottle for contributing to this release! Thanks to @jacobeva, @faragher, @nathmo, @jschulthess and @liamcottle for contributing to this release!

View File

@ -2,7 +2,7 @@ all: release
test: test:
@echo Running tests... @echo Running tests...
python -m tests.all python3 -m tests.all
clean: clean:
@echo Cleaning... @echo Cleaning...

View File

@ -1,4 +1,4 @@
Reticulum Network Stack β <img align="right" src="https://static.pepy.tech/personalized-badge/rns?period=total&units=international_system&left_color=grey&right_color=blue&left_text=Installs"/> Reticulum Network Stack β <img align="right" src="https://static.pepy.tech/personalized-badge/rns?period=total&units=international_system&left_color=grey&right_color=blue&left_text=Installs" style="padding-left:10px"/><a href="https://github.com/markqvist/reticulum/actions/workflows/python-app.yml"><img align="right" src="https://github.com/markqvist/reticulum/actions/workflows/python-app.yml/badge.svg"/></a>
========== ==========
<p align="center"><img width="200" src="https://raw.githubusercontent.com/markqvist/Reticulum/master/docs/source/graphics/rns_logo_512.png"></p> <p align="center"><img width="200" src="https://raw.githubusercontent.com/markqvist/Reticulum/master/docs/source/graphics/rns_logo_512.png"></p>

View File

@ -23,6 +23,7 @@
import os import os
import math import math
import time import time
import threading
import RNS import RNS
from RNS.Cryptography import Fernet from RNS.Cryptography import Fernet
@ -152,8 +153,10 @@ class Destination:
self.ratchets = None self.ratchets = None
self.ratchets_path = None self.ratchets_path = None
self.ratchet_interval = Destination.RATCHET_INTERVAL self.ratchet_interval = Destination.RATCHET_INTERVAL
self.ratchet_file_lock = threading.Lock()
self.retained_ratchets = Destination.RATCHET_COUNT self.retained_ratchets = Destination.RATCHET_COUNT
self.latest_ratchet_time = None self.latest_ratchet_time = None
self.latest_ratchet_id = None
self.__enforce_ratchets = False self.__enforce_ratchets = False
self.mtu = 0 self.mtu = 0
@ -195,11 +198,12 @@ class Destination:
def _persist_ratchets(self): def _persist_ratchets(self):
try: try:
packed_ratchets = umsgpack.packb(self.ratchets) with self.ratchet_file_lock:
persisted_data = {"signature": self.sign(packed_ratchets), "ratchets": packed_ratchets} packed_ratchets = umsgpack.packb(self.ratchets)
ratchets_file = open(self.ratchets_path, "wb") persisted_data = {"signature": self.sign(packed_ratchets), "ratchets": packed_ratchets}
ratchets_file.write(umsgpack.packb(persisted_data)) ratchets_file = open(self.ratchets_path, "wb")
ratchets_file.close() ratchets_file.write(umsgpack.packb(persisted_data))
ratchets_file.close()
except Exception as e: except Exception as e:
self.ratchets = None self.ratchets = None
self.ratchets_path = None self.ratchets_path = None
@ -265,9 +269,7 @@ class Destination:
if self.ratchets != None: if self.ratchets != None:
self.rotate_ratchets() self.rotate_ratchets()
ratchet = RNS.Identity._ratchet_public_bytes(self.ratchets[0]) ratchet = RNS.Identity._ratchet_public_bytes(self.ratchets[0])
RNS.Identity._remember_ratchet(self.hash, ratchet)
# TODO: Remove at some point
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):
@ -401,6 +403,7 @@ class Destination:
self.incoming_link_request(plaintext, packet) self.incoming_link_request(plaintext, packet)
else: else:
plaintext = self.decrypt(packet.data) plaintext = self.decrypt(packet.data)
packet.ratchet_id = self.latest_ratchet_id
if plaintext != None: if plaintext != None:
if packet.packet_type == RNS.Packet.DATA: if packet.packet_type == RNS.Packet.DATA:
if self.callbacks.packet != None: if self.callbacks.packet != None:
@ -415,6 +418,29 @@ class Destination:
if link != None: if link != None:
self.links.append(link) self.links.append(link)
def _reload_ratchets(self, ratchets_path):
if os.path.isfile(ratchets_path):
with self.ratchet_file_lock:
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()
def enable_ratchets(self, ratchets_path): def enable_ratchets(self, ratchets_path):
""" """
Enables ratchets on the destination. When ratchets are enabled, Reticulum will automatically rotate Enables ratchets on the destination. When ratchets are enabled, Reticulum will automatically rotate
@ -432,26 +458,7 @@ class Destination:
""" """
if ratchets_path != None: if ratchets_path != None:
self.latest_ratchet_time = 0 self.latest_ratchet_time = 0
if os.path.isfile(ratchets_path): self._reload_ratchets(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 # TODO: Remove at some point
RNS.log("Ratchets enabled on "+str(self), RNS.LOG_DEBUG) RNS.log("Ratchets enabled on "+str(self), RNS.LOG_DEBUG)
@ -565,7 +572,10 @@ 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, ratchet=RNS.Identity.get_ratchet(self.hash)) selected_ratchet = RNS.Identity.get_ratchet(self.hash)
if selected_ratchet:
self.latest_ratchet_id = RNS.Identity._get_ratchet_id(selected_ratchet)
return self.identity.encrypt(plaintext, ratchet=selected_ratchet)
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:
@ -588,7 +598,28 @@ 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, enforce_ratchets=self.__enforce_ratchets) if self.ratchets:
decrypted = None
try:
decrypted = self.identity.decrypt(ciphertext, ratchets=self.ratchets, enforce_ratchets=self.__enforce_ratchets, ratchet_id_receiver=self)
except:
decrypted = None
if not decrypted:
try:
RNS.log(f"Decryption with ratchets failed on {self}, reloading ratchets from storage and retrying", RNS.LOG_ERROR)
self._reload_ratchets(self.ratchets_path)
decrypted = self.identity.decrypt(ciphertext, ratchets=self.ratchets, enforce_ratchets=self.__enforce_ratchets, ratchet_id_receiver=self)
except Exception as e:
RNS.log(f"Decryption still failing after ratchet reload. The contained exception was: {e}", RNS.LOG_ERROR)
raise e
RNS.log("Decryption succeeded after ratchet reload", RNS.LOG_NOTICE)
return decrypted
else:
return self.identity.decrypt(ciphertext, ratchets=None, enforce_ratchets=self.__enforce_ratchets, ratchet_id_receiver=self)
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

@ -213,7 +213,7 @@ class Identity:
Get a SHA-256 hash of passed data. Get a SHA-256 hash of passed data.
:param data: Data to be hashed as *bytes*. :param data: Data to be hashed as *bytes*.
:returns: SHA-256 hash as *bytes* :returns: SHA-256 hash as *bytes*.
""" """
return RNS.Cryptography.sha256(data) return RNS.Cryptography.sha256(data)
@ -223,7 +223,7 @@ class Identity:
Get a truncated SHA-256 hash of passed data. Get a truncated SHA-256 hash of passed data.
:param data: Data to be hashed as *bytes*. :param data: Data to be hashed as *bytes*.
:returns: Truncated SHA-256 hash as *bytes* :returns: Truncated SHA-256 hash as *bytes*.
""" """
return Identity.full_hash(data)[:(Identity.TRUNCATED_HASHLENGTH//8)] return Identity.full_hash(data)[:(Identity.TRUNCATED_HASHLENGTH//8)]
@ -233,10 +233,28 @@ class Identity:
Get a random SHA-256 hash. Get a random SHA-256 hash.
:param data: Data to be hashed as *bytes*. :param data: Data to be hashed as *bytes*.
:returns: Truncated SHA-256 hash of random data as *bytes* :returns: Truncated SHA-256 hash of random data as *bytes*.
""" """
return Identity.truncated_hash(os.urandom(Identity.TRUNCATED_HASHLENGTH//8)) return Identity.truncated_hash(os.urandom(Identity.TRUNCATED_HASHLENGTH//8))
@staticmethod
def current_ratchet_id(destination_hash):
"""
Get the ID of the currently used ratchet key for a given destination hash
:param destination_hash: A destination hash as *bytes*.
:returns: A ratchet ID as *bytes* or *None*.
"""
ratchet = Identity.get_ratchet(destination_hash)
if ratchet == None:
return None
else:
return Identity._get_ratchet_id(ratchet)
@staticmethod
def _get_ratchet_id(ratchet_pub_bytes):
return Identity.full_hash(ratchet_pub_bytes)[:Identity.NAME_HASH_LENGTH//8]
@staticmethod @staticmethod
def _ratchet_public_bytes(ratchet): def _ratchet_public_bytes(ratchet):
return X25519PrivateKey.from_private_bytes(ratchet).public_key().public_bytes() return X25519PrivateKey.from_private_bytes(ratchet).public_key().public_bytes()
@ -250,7 +268,7 @@ class Identity:
@staticmethod @staticmethod
def _remember_ratchet(destination_hash, ratchet): def _remember_ratchet(destination_hash, ratchet):
# TODO: Remove at some point, and only log new ratchets # TODO: Remove at some point, and only log new ratchets
RNS.log(f"Remembering ratchet {RNS.prettyhexrep(Identity.truncated_hash(ratchet))} for {RNS.prettyhexrep(destination_hash)}", RNS.LOG_EXTREME) RNS.log(f"Remembering ratchet {RNS.prettyhexrep(Identity._get_ratchet_id(ratchet))} for {RNS.prettyhexrep(destination_hash)}", RNS.LOG_EXTREME)
try: try:
Identity.known_ratchets[destination_hash] = ratchet Identity.known_ratchets[destination_hash] = ratchet
@ -270,7 +288,7 @@ class Identity:
ratchet_file = open(outpath, "wb") ratchet_file = open(outpath, "wb")
ratchet_file.write(umsgpack.packb(ratchet_data)) ratchet_file.write(umsgpack.packb(ratchet_data))
ratchet_file.close() ratchet_file.close()
os.rename(outpath, finalpath) os.replace(outpath, finalpath)
threading.Thread(target=persist_job, daemon=True).start() threading.Thread(target=persist_job, daemon=True).start()
@ -310,11 +328,11 @@ class Identity:
if not destination_hash in Identity.known_ratchets: if not destination_hash in Identity.known_ratchets:
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}"
if os.path.isfile(ratchet_path): if os.path.isfile(ratchet_path):
try: try:
ratchet_file = open(ratchet_path, "rb") ratchet_file = open(ratchet_path, "rb")
ratchet_data = umsgpack.unpackb(ratchets_file.read()) ratchet_data = umsgpack.unpackb(ratchet_file.read())
if time.time() < ratchet_data["received"]+Identity.RATCHET_EXPIRY and len(ratchet_data["ratchet"]) == Identity.RATCHETSIZE//8: 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"] Identity.known_ratchets[destination_hash] = ratchet_data["ratchet"]
else: else:
@ -617,8 +635,6 @@ 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:
# 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
@ -641,7 +657,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, enforce_ratchets=False): def decrypt(self, ciphertext_token, ratchets=None, enforce_ratchets=False, ratchet_id_receiver=None):
""" """
Decrypts information for the identity. Decrypts information for the identity.
@ -661,6 +677,7 @@ class Identity:
for ratchet in ratchets: for ratchet in ratchets:
try: try:
ratchet_prv = X25519PrivateKey.from_private_bytes(ratchet) ratchet_prv = X25519PrivateKey.from_private_bytes(ratchet)
ratchet_id = Identity._get_ratchet_id(ratchet_prv.public_key().public_bytes())
shared_key = ratchet_prv.exchange(peer_pub) shared_key = ratchet_prv.exchange(peer_pub)
derived_key = RNS.Cryptography.hkdf( derived_key = RNS.Cryptography.hkdf(
length=32, length=32,
@ -671,9 +688,8 @@ class Identity:
fernet = Fernet(derived_key) fernet = Fernet(derived_key)
plaintext = fernet.decrypt(ciphertext) plaintext = fernet.decrypt(ciphertext)
if ratchet_id_receiver:
# TODO: Remove at some point ratchet_id_receiver.latest_ratchet_id = ratchet_id
RNS.log(f"Decrypted with ratchet {RNS.prettyhexrep(RNS.Identity.truncated_hash(ratchet_prv.public_key().public_bytes()))}", RNS.LOG_EXTREME)
break break
@ -682,6 +698,8 @@ class Identity:
if enforce_ratchets and plaintext == None: if enforce_ratchets and plaintext == None:
RNS.log("Decryption with ratchet enforcement by "+RNS.prettyhexrep(self.hash)+" failed. Dropping packet.", RNS.LOG_DEBUG) RNS.log("Decryption with ratchet enforcement by "+RNS.prettyhexrep(self.hash)+" failed. Dropping packet.", RNS.LOG_DEBUG)
if ratchet_id_receiver:
ratchet_id_receiver.latest_ratchet_id = None
return None return None
if plaintext == None: if plaintext == None:
@ -695,9 +713,13 @@ class Identity:
fernet = Fernet(derived_key) fernet = Fernet(derived_key)
plaintext = fernet.decrypt(ciphertext) plaintext = fernet.decrypt(ciphertext)
if ratchet_id_receiver:
ratchet_id_receiver.latest_ratchet_id = None
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)
if ratchet_id_receiver:
ratchet_id_receiver.latest_ratchet_id = None
return plaintext; return plaintext;
else: else:

View File

@ -81,6 +81,7 @@ class KISS():
PLATFORM_AVR = 0x90 PLATFORM_AVR = 0x90
PLATFORM_ESP32 = 0x80 PLATFORM_ESP32 = 0x80
PLATFORM_NRF52 = 0x70
@staticmethod @staticmethod
def escape(data): def escape(data):
@ -595,7 +596,7 @@ class RNodeInterface(Interface):
if not self.detected: if not self.detected:
raise IOError("Could not detect device") raise IOError("Could not detect device")
else: else:
if self.platform == KISS.PLATFORM_ESP32: if self.platform == KISS.PLATFORM_ESP32 or self.platform == KISS.PLATFORM_NRF52:
self.display = True self.display = True
if not self.firmware_ok: if not self.firmware_ok:

View File

@ -80,6 +80,7 @@ class KISS():
PLATFORM_AVR = 0x90 PLATFORM_AVR = 0x90
PLATFORM_ESP32 = 0x80 PLATFORM_ESP32 = 0x80
PLATFORM_NRF52 = 0x70
@staticmethod @staticmethod
def escape(data): def escape(data):
@ -285,7 +286,7 @@ class RNodeInterface(Interface):
RNS.log("Could not detect device for "+str(self), RNS.LOG_ERROR) RNS.log("Could not detect device for "+str(self), RNS.LOG_ERROR)
self.serial.close() self.serial.close()
else: else:
if self.platform == KISS.PLATFORM_ESP32: if self.platform == KISS.PLATFORM_ESP32 or self.platform == KISS.PLATFORM_NRF52:
self.display = True self.display = True
RNS.log("Serial port "+self.port+" is now open") RNS.log("Serial port "+self.port+" is now open")
@ -622,7 +623,6 @@ class RNodeInterface(Interface):
self.r_state = byte self.r_state = byte
if self.r_state: if self.r_state:
pass pass
#RNS.log(str(self)+" Radio reporting state is online", RNS.LOG_DEBUG)
else: else:
RNS.log(str(self)+" Radio reporting state is offline", RNS.LOG_DEBUG) RNS.log(str(self)+" Radio reporting state is offline", RNS.LOG_DEBUG)

View File

@ -106,6 +106,7 @@ class KISS():
PLATFORM_AVR = 0x90 PLATFORM_AVR = 0x90
PLATFORM_ESP32 = 0x80 PLATFORM_ESP32 = 0x80
PLATFORM_NRF52 = 0x70
SX127X = 0x00 SX127X = 0x00
SX1276 = 0x01 SX1276 = 0x01
@ -297,7 +298,7 @@ class RNodeMultiInterface(Interface):
RNS.log("Could not detect device for "+str(self), RNS.LOG_ERROR) RNS.log("Could not detect device for "+str(self), RNS.LOG_ERROR)
self.serial.close() self.serial.close()
else: else:
if self.platform == KISS.PLATFORM_ESP32: if self.platform == KISS.PLATFORM_ESP32 or self.platform == KISS.PLATFORM_NRF52:
self.display = True self.display = True
RNS.log("Serial port "+self.port+" is now open") RNS.log("Serial port "+self.port+" is now open")

View File

@ -775,6 +775,7 @@ class Link:
should_query = False should_query = False
if packet.context == RNS.Packet.NONE: if packet.context == RNS.Packet.NONE:
plaintext = self.decrypt(packet.data) plaintext = self.decrypt(packet.data)
packet.ratchet_id = self.link_id
if plaintext != None: if plaintext != None:
if self.callbacks.packet != None: if self.callbacks.packet != None:
thread = threading.Thread(target=self.callbacks.packet, args=(plaintext, packet)) thread = threading.Thread(target=self.callbacks.packet, args=(plaintext, packet))

View File

@ -140,6 +140,7 @@ class Packet:
self.MTU = RNS.Reticulum.MTU self.MTU = RNS.Reticulum.MTU
self.sent_at = None self.sent_at = None
self.packet_hash = None self.packet_hash = None
self.ratchet_id = None
self.attached_interface = attached_interface self.attached_interface = attached_interface
self.receiving_interface = None self.receiving_interface = None
@ -195,6 +196,8 @@ class Packet:
# In all other cases, we encrypt the packet # In all other cases, we encrypt the packet
# with the destination's encryption method # with the destination's encryption method
self.ciphertext = self.destination.encrypt(self.data) self.ciphertext = self.destination.encrypt(self.data)
if hasattr(self.destination, "latest_ratchet_id"):
self.ratchet_id = self.destination.latest_ratchet_id
if self.header_type == Packet.HEADER_2: if self.header_type == Packet.HEADER_2:
if self.transport_id != None: if self.transport_id != None:
@ -418,6 +421,7 @@ class PacketReceipt:
except Exception as e: except Exception as e:
RNS.log("An error occurred while evaluating external delivery callback for "+str(link), RNS.LOG_ERROR) RNS.log("An error occurred while evaluating external delivery callback for "+str(link), RNS.LOG_ERROR)
RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR)
RNS.trace_exception(e)
return True return True
else: else:

View File

@ -52,16 +52,16 @@ class Transport:
Maximum amount of hops that Reticulum will transport a packet. Maximum amount of hops that Reticulum will transport a packet.
""" """
PATHFINDER_R = 1 # Retransmit retries PATHFINDER_R = 1 # Retransmit retries
PATHFINDER_G = 5 # Retry grace period PATHFINDER_G = 5 # Retry grace period
PATHFINDER_RW = 0.5 # Random window for announce rebroadcast PATHFINDER_RW = 0.5 # Random window for announce rebroadcast
PATHFINDER_E = 60*60*24*7 # Path expiration of one week PATHFINDER_E = 60*60*24*7 # Path expiration of one week
AP_PATH_TIME = 60*60*24 # Path expiration of one day for Access Point paths AP_PATH_TIME = 60*60*24 # Path expiration of one day for Access Point paths
ROAMING_PATH_TIME = 60*60*6 # Path expiration of 6 hours for Roaming paths ROAMING_PATH_TIME = 60*60*6 # Path expiration of 6 hours for Roaming paths
# TODO: Calculate an optimal number for this in # TODO: Calculate an optimal number for this in
# various situations # various situations
LOCAL_REBROADCASTS_MAX = 2 # How many local rebroadcasts of an announce is allowed LOCAL_REBROADCASTS_MAX = 2 # How many local rebroadcasts of an announce is allowed
PATH_REQUEST_TIMEOUT = 15 # Default timuout for client path requests in seconds PATH_REQUEST_TIMEOUT = 15 # Default timuout for client path requests in seconds
PATH_REQUEST_GRACE = 0.4 # Grace time before a path announcement is made, allows directly reachable peers to respond first PATH_REQUEST_GRACE = 0.4 # Grace time before a path announcement is made, allows directly reachable peers to respond first
@ -1709,6 +1709,7 @@ class Transport:
except Exception as e: except Exception as e:
RNS.log("Error while processing external announce callback.", RNS.LOG_ERROR) RNS.log("Error while processing external announce callback.", RNS.LOG_ERROR)
RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR)
RNS.trace_exception(e)
# Handling for link requests to local destinations # Handling for link requests to local destinations
elif packet.packet_type == RNS.Packet.LINKREQUEST: elif packet.packet_type == RNS.Packet.LINKREQUEST:

View File

@ -128,10 +128,6 @@ class ROM():
MCU_ESP32 = 0x81 MCU_ESP32 = 0x81
MCU_NRF52 = 0x71 MCU_NRF52 = 0x71
PRODUCT_RAK4631 = 0x10
MODEL_11 = 0x11
MODEL_12 = 0x12
PRODUCT_RNODE = 0x03 PRODUCT_RNODE = 0x03
MODEL_A1 = 0xA1 MODEL_A1 = 0xA1
MODEL_A6 = 0xA6 MODEL_A6 = 0xA6
@ -171,6 +167,14 @@ class ROM():
MODEL_E3 = 0xE3 MODEL_E3 = 0xE3
MODEL_E8 = 0xE8 MODEL_E8 = 0xE8
PRODUCT_RAK4631 = 0x10
MODEL_11 = 0x11
MODEL_12 = 0x12
PRODUCT_TECHO = 0x15
MODEL_T4 = 0x16
MODEL_T9 = 0x17
PRODUCT_HMBRW = 0xF0 PRODUCT_HMBRW = 0xF0
MODEL_FF = 0xFF MODEL_FF = 0xFF
MODEL_FE = 0xFE MODEL_FE = 0xFE
@ -200,6 +204,7 @@ class ROM():
BOARD_GENERIC_ESP32 = 0x35 BOARD_GENERIC_ESP32 = 0x35
BOARD_LORA32_V2_0 = 0x36 BOARD_LORA32_V2_0 = 0x36
BOARD_LORA32_V2_1 = 0x37 BOARD_LORA32_V2_1 = 0x37
BOARD_TECHO = 0x43
BOARD_RAK4631 = 0x51 BOARD_RAK4631 = 0x51
MANUAL_FLASH_MODELS = [MODEL_A1, MODEL_A6] MANUAL_FLASH_MODELS = [MODEL_A1, MODEL_A6]
@ -214,6 +219,7 @@ products = {
ROM.PRODUCT_T32_21: "LilyGO LoRa32 v2.1", ROM.PRODUCT_T32_21: "LilyGO LoRa32 v2.1",
ROM.PRODUCT_H32_V2: "Heltec LoRa32 v2", ROM.PRODUCT_H32_V2: "Heltec LoRa32 v2",
ROM.PRODUCT_H32_V3: "Heltec LoRa32 v3", ROM.PRODUCT_H32_V3: "Heltec LoRa32 v3",
ROM.PRODUCT_TECHO: "LilyGO T-Echo",
ROM.PRODUCT_RAK4631: "RAK4631", ROM.PRODUCT_RAK4631: "RAK4631",
} }
@ -231,8 +237,6 @@ mcus = {
} }
models = { models = {
0x11: [430000000, 510000000, 22, "430 - 510 MHz", "rnode_firmware_rak4631.zip", "SX1262"],
0x12: [779000000, 928000000, 22, "779 - 928 MHz", "rnode_firmware_rak4631.zip", "SX1262"],
0xA4: [410000000, 525000000, 14, "410 - 525 MHz", "rnode_firmware.hex", "SX1278"], 0xA4: [410000000, 525000000, 14, "410 - 525 MHz", "rnode_firmware.hex", "SX1278"],
0xA9: [820000000, 1020000000, 17, "820 - 1020 MHz", "rnode_firmware.hex", "SX1276"], 0xA9: [820000000, 1020000000, 17, "820 - 1020 MHz", "rnode_firmware.hex", "SX1276"],
0xA1: [410000000, 525000000, 22, "410 - 525 MHz", "rnode_firmware_t3s3.zip", "SX1268"], 0xA1: [410000000, 525000000, 22, "410 - 525 MHz", "rnode_firmware_t3s3.zip", "SX1268"],
@ -257,6 +261,10 @@ models = {
0xE9: [850000000, 950000000, 17, "850 - 950 MHz", "rnode_firmware_tbeam.zip", "SX1276"], 0xE9: [850000000, 950000000, 17, "850 - 950 MHz", "rnode_firmware_tbeam.zip", "SX1276"],
0xE3: [420000000, 520000000, 22, "420 - 520 MHz", "rnode_firmware_tbeam_sx1262.zip", "SX1268"], 0xE3: [420000000, 520000000, 22, "420 - 520 MHz", "rnode_firmware_tbeam_sx1262.zip", "SX1268"],
0xE8: [850000000, 950000000, 22, "850 - 950 MHz", "rnode_firmware_tbeam_sx1262.zip", "SX1262"], 0xE8: [850000000, 950000000, 22, "850 - 950 MHz", "rnode_firmware_tbeam_sx1262.zip", "SX1262"],
0x11: [430000000, 510000000, 22, "430 - 510 MHz", "rnode_firmware_rak4631.zip", "SX1262"],
0x12: [779000000, 928000000, 22, "779 - 928 MHz", "rnode_firmware_rak4631.zip", "SX1262"],
0x16: [779000000, 928000000, 22, "430 - 510 Mhz", "rnode_firmware_techo.zip", "SX1262"],
0x17: [779000000, 928000000, 22, "779 - 928 Mhz", "rnode_firmware_techo.zip", "SX1262"],
0xFE: [100000000, 1100000000, 17, "(Band capabilities unknown)", None, "Unknown"], 0xFE: [100000000, 1100000000, 17, "(Band capabilities unknown)", None, "Unknown"],
0xFF: [100000000, 1100000000, 14, "(Band capabilities unknown)", None, "Unknown"], 0xFF: [100000000, 1100000000, 14, "(Band capabilities unknown)", None, "Unknown"],
} }
@ -1603,6 +1611,7 @@ def main():
print("[8] Heltec LoRa32 v3") print("[8] Heltec LoRa32 v3")
print("[9] LilyGO LoRa T3S3") print("[9] LilyGO LoRa T3S3")
print("[10] RAK4631") print("[10] RAK4631")
print("[11] LilyGo T-Echo")
print(" .") print(" .")
print(" / \\ Select one of these options if you want to easily turn") print(" / \\ Select one of these options if you want to easily turn")
print(" | a supported development board into an RNode.") print(" | a supported development board into an RNode.")
@ -1614,7 +1623,7 @@ def main():
try: try:
c_dev = int(input()) c_dev = int(input())
c_mod = False c_mod = False
if c_dev < 1 or c_dev > 10: if c_dev < 1 or c_dev > 11:
raise ValueError() raise ValueError()
elif c_dev == 1: elif c_dev == 1:
selected_product = ROM.PRODUCT_RNODE selected_product = ROM.PRODUCT_RNODE
@ -1756,6 +1765,19 @@ def main():
print("who would like to experiment with it. Hit enter to continue.") print("who would like to experiment with it. Hit enter to continue.")
print("---------------------------------------------------------------------------") print("---------------------------------------------------------------------------")
input() input()
elif c_dev == 11:
selected_product = ROM.PRODUCT_TECHO
clear()
print("")
print("---------------------------------------------------------------------------")
print(" LilyGo T-Echo RNode Installer")
print("")
print("Important! Using RNode firmware on LilyGo T-Echo devices should currently be")
print("considered experimental. It is not intended for production or critical use.")
print("The currently supplied firmware is provided AS-IS as a courtesey to those")
print("who would like to experiment with it. Hit enter to continue.")
print("---------------------------------------------------------------------------")
input()
except Exception as e: except Exception as e:
print("That device type does not exist, exiting now.") print("That device type does not exist, exiting now.")
graceful_exit() graceful_exit()
@ -2042,6 +2064,27 @@ def main():
except Exception as e: except Exception as e:
print("That band does not exist, exiting now.") print("That band does not exist, exiting now.")
graceful_exit() graceful_exit()
elif selected_product == ROM.PRODUCT_TECHO:
selected_mcu = ROM.MCU_NRF52
print("\nWhat band is this T-Echo for?\n")
print("[1] 433 MHz")
print("[2] 868 MHz")
print("[3] 915 MHz")
print("[4] 923 MHz")
print("\n? ", end="")
try:
c_model = int(input())
if c_model < 1 or c_model > 1:
raise ValueError()
elif c_model == 1:
selected_model = ROM.MODEL_T4
selected_platform = ROM.PLATFORM_NRF52
elif c_model > 1:
selected_model = ROM.MODEL_T9
selected_platform = ROM.PLATFORM_NRF52
except Exception as e:
print("That band does not exist, exiting now.")
graceful_exit()
if selected_model != ROM.MODEL_FF and selected_model != ROM.MODEL_FE: if selected_model != ROM.MODEL_FF and selected_model != ROM.MODEL_FE:
fw_filename = models[selected_model][4] fw_filename = models[selected_model][4]

View File

@ -15,6 +15,9 @@ This document outlines the currently established development roadmap for Reticul
For each release cycle of Reticulum, improvements and additions from the five [Primary Efforts](#primary-efforts) are selected as active work areas, and can be expected to be included in the upcoming releases within that cycle. While not entirely set in stone for each release cycle, they serve as a pointer of what to expect in the near future. For each release cycle of Reticulum, improvements and additions from the five [Primary Efforts](#primary-efforts) are selected as active work areas, and can be expected to be included in the upcoming releases within that cycle. While not entirely set in stone for each release cycle, they serve as a pointer of what to expect in the near future.
- The current `0.7.x` release cycle aims at completing - The current `0.7.x` release cycle aims at completing
- [x] Automatic asynchronous key ratcheting for non-link traffic
- [ ] API improvements based on real-world usage and feedback
- [ ] Expanded hardware support
- [ ] Overhauling and updating the documentation - [ ] Overhauling and updating the documentation
- [ ] Distributed Destination Naming System - [ ] Distributed Destination Naming System
- [ ] Create a standalone RNS Daemon app for Android - [ ] Create a standalone RNS Daemon app for Android

Binary file not shown.

Binary file not shown.

View File

@ -293,6 +293,8 @@
<li><a href="reference.html#RNS.Buffer.create_reader">create_reader() (RNS.Buffer static method)</a> <li><a href="reference.html#RNS.Buffer.create_reader">create_reader() (RNS.Buffer static method)</a>
</li> </li>
<li><a href="reference.html#RNS.Buffer.create_writer">create_writer() (RNS.Buffer static method)</a> <li><a href="reference.html#RNS.Buffer.create_writer">create_writer() (RNS.Buffer static method)</a>
</li>
<li><a href="reference.html#RNS.Identity.current_ratchet_id">current_ratchet_id() (RNS.Identity static method)</a>
</li> </li>
<li><a href="reference.html#RNS.Identity.CURVE">CURVE (RNS.Identity attribute)</a> <li><a href="reference.html#RNS.Identity.CURVE">CURVE (RNS.Identity attribute)</a>

Binary file not shown.

View File

@ -417,7 +417,7 @@ for addressable hashes and other purposes. Non-configurable.</p>
<dd class="field-odd"><p><strong>data</strong> Data to be hashed as <em>bytes</em>.</p> <dd class="field-odd"><p><strong>data</strong> Data to be hashed as <em>bytes</em>.</p>
</dd> </dd>
<dt class="field-even">Returns<span class="colon">:</span></dt> <dt class="field-even">Returns<span class="colon">:</span></dt>
<dd class="field-even"><p>SHA-256 hash as <em>bytes</em></p> <dd class="field-even"><p>SHA-256 hash as <em>bytes</em>.</p>
</dd> </dd>
</dl> </dl>
</dd></dl> </dd></dl>
@ -431,7 +431,7 @@ for addressable hashes and other purposes. Non-configurable.</p>
<dd class="field-odd"><p><strong>data</strong> Data to be hashed as <em>bytes</em>.</p> <dd class="field-odd"><p><strong>data</strong> Data to be hashed as <em>bytes</em>.</p>
</dd> </dd>
<dt class="field-even">Returns<span class="colon">:</span></dt> <dt class="field-even">Returns<span class="colon">:</span></dt>
<dd class="field-even"><p>Truncated SHA-256 hash as <em>bytes</em></p> <dd class="field-even"><p>Truncated SHA-256 hash as <em>bytes</em>.</p>
</dd> </dd>
</dl> </dl>
</dd></dl> </dd></dl>
@ -445,7 +445,21 @@ for addressable hashes and other purposes. Non-configurable.</p>
<dd class="field-odd"><p><strong>data</strong> Data to be hashed as <em>bytes</em>.</p> <dd class="field-odd"><p><strong>data</strong> Data to be hashed as <em>bytes</em>.</p>
</dd> </dd>
<dt class="field-even">Returns<span class="colon">:</span></dt> <dt class="field-even">Returns<span class="colon">:</span></dt>
<dd class="field-even"><p>Truncated SHA-256 hash of random data as <em>bytes</em></p> <dd class="field-even"><p>Truncated SHA-256 hash of random data as <em>bytes</em>.</p>
</dd>
</dl>
</dd></dl>
<dl class="py method">
<dt class="sig sig-object py" id="RNS.Identity.current_ratchet_id">
<em class="property"><span class="pre">static</span><span class="w"> </span></em><span class="sig-name descname"><span class="pre">current_ratchet_id</span></span><span class="sig-paren">(</span><em class="sig-param"><span class="n"><span class="pre">destination_hash</span></span></em><span class="sig-paren">)</span><a class="headerlink" href="#RNS.Identity.current_ratchet_id" title="Permalink to this definition">#</a></dt>
<dd><p>Get the ID of the currently used ratchet key for a given destination hash</p>
<dl class="field-list simple">
<dt class="field-odd">Parameters<span class="colon">:</span></dt>
<dd class="field-odd"><p><strong>destination_hash</strong> A destination hash as <em>bytes</em>.</p>
</dd>
<dt class="field-even">Returns<span class="colon">:</span></dt>
<dd class="field-even"><p>A ratchet ID as <em>bytes</em> or <em>None</em>.</p>
</dd> </dd>
</dl> </dl>
</dd></dl> </dd></dl>
@ -563,7 +577,7 @@ communication for the identity. Be very careful with this method.</p>
<dl class="py method"> <dl class="py method">
<dt class="sig sig-object py" id="RNS.Identity.decrypt"> <dt class="sig sig-object py" id="RNS.Identity.decrypt">
<span class="sig-name descname"><span class="pre">decrypt</span></span><span class="sig-paren">(</span><em class="sig-param"><span class="n"><span class="pre">ciphertext_token</span></span></em>, <em class="sig-param"><span class="n"><span class="pre">ratchets</span></span><span class="o"><span class="pre">=</span></span><span class="default_value"><span class="pre">None</span></span></em>, <em class="sig-param"><span class="n"><span class="pre">enforce_ratchets</span></span><span class="o"><span class="pre">=</span></span><span class="default_value"><span class="pre">False</span></span></em><span class="sig-paren">)</span><a class="headerlink" href="#RNS.Identity.decrypt" title="Permalink to this definition">#</a></dt> <span class="sig-name descname"><span class="pre">decrypt</span></span><span class="sig-paren">(</span><em class="sig-param"><span class="n"><span class="pre">ciphertext_token</span></span></em>, <em class="sig-param"><span class="n"><span class="pre">ratchets</span></span><span class="o"><span class="pre">=</span></span><span class="default_value"><span class="pre">None</span></span></em>, <em class="sig-param"><span class="n"><span class="pre">enforce_ratchets</span></span><span class="o"><span class="pre">=</span></span><span class="default_value"><span class="pre">False</span></span></em>, <em class="sig-param"><span class="n"><span class="pre">ratchet_id_receiver</span></span><span class="o"><span class="pre">=</span></span><span class="default_value"><span class="pre">None</span></span></em><span class="sig-paren">)</span><a class="headerlink" href="#RNS.Identity.decrypt" title="Permalink to this definition">#</a></dt>
<dd><p>Decrypts information for the identity.</p> <dd><p>Decrypts information for the identity.</p>
<dl class="field-list simple"> <dl class="field-list simple">
<dt class="field-odd">Parameters<span class="colon">:</span></dt> <dt class="field-odd">Parameters<span class="colon">:</span></dt>
@ -2058,6 +2072,7 @@ will announce it.</p>
<li><a class="reference internal" href="#RNS.Identity.full_hash"><code class="docutils literal notranslate"><span class="pre">full_hash()</span></code></a></li> <li><a class="reference internal" href="#RNS.Identity.full_hash"><code class="docutils literal notranslate"><span class="pre">full_hash()</span></code></a></li>
<li><a class="reference internal" href="#RNS.Identity.truncated_hash"><code class="docutils literal notranslate"><span class="pre">truncated_hash()</span></code></a></li> <li><a class="reference internal" href="#RNS.Identity.truncated_hash"><code class="docutils literal notranslate"><span class="pre">truncated_hash()</span></code></a></li>
<li><a class="reference internal" href="#RNS.Identity.get_random_hash"><code class="docutils literal notranslate"><span class="pre">get_random_hash()</span></code></a></li> <li><a class="reference internal" href="#RNS.Identity.get_random_hash"><code class="docutils literal notranslate"><span class="pre">get_random_hash()</span></code></a></li>
<li><a class="reference internal" href="#RNS.Identity.current_ratchet_id"><code class="docutils literal notranslate"><span class="pre">current_ratchet_id()</span></code></a></li>
<li><a class="reference internal" href="#RNS.Identity.from_bytes"><code class="docutils literal notranslate"><span class="pre">from_bytes()</span></code></a></li> <li><a class="reference internal" href="#RNS.Identity.from_bytes"><code class="docutils literal notranslate"><span class="pre">from_bytes()</span></code></a></li>
<li><a class="reference internal" href="#RNS.Identity.from_file"><code class="docutils literal notranslate"><span class="pre">from_file()</span></code></a></li> <li><a class="reference internal" href="#RNS.Identity.from_file"><code class="docutils literal notranslate"><span class="pre">from_file()</span></code></a></li>
<li><a class="reference internal" href="#RNS.Identity.to_file"><code class="docutils literal notranslate"><span class="pre">to_file()</span></code></a></li> <li><a class="reference internal" href="#RNS.Identity.to_file"><code class="docutils literal notranslate"><span class="pre">to_file()</span></code></a></li>

File diff suppressed because one or more lines are too long