Proof-of-concept for decentralised protocol for peer-to-peer (P2P) communication.
Project for Cardiff University Computing For Mathematics module.
Theory behind the project and detailed description of the communication protocol is in this paper.
The key idea of this communication protocol is for each peer of the network to have a local "routing table" - here we've called them "Address Books", and "User Records", which contain a user's public key, their IP address, the date and time of the generation of this record and the digital signature for this record. The digital signature is used by other peers to verify that the "User Record" was in fact generated by the user with the given public key.
We chose to use RSA public keys and their SHA-256 hashes as "usernames" instead of directly using IP addresses, because IP addresses can change, so do not directly provide user authentication.
Each peer stores all records of other users locally, and uses them for communication, sending and routing their requests or other user's requests.
There are two types of request for this communication protocol: UPDATE requests and SEARCH requests. These requests are used to exchange User Records in the network and are routed recursively with a predetermined "recursion depth".
UPDATE requests are sent by a user to all users in their Address Book and contain their User Record, recursion depth (which is decreased by 1 after it is received by another user) and a list of hashes of users which this request was already sent to minimise repetition. After each user receives the request, they verify the digital signature. If it is successfully verified, they update the User Record in their Address Book if this user was in the Address Book, or add a new one if they weren't. They then pass this request to all users in their Address Book. This continues until the "recursion depth" is 0.
SEARCH requests are sent by a user to all users in their Address Book when the user wants to find another user who is not in their Address Book, or to look for updates of a User Record if a user is unreachable via their IP address. As well as UPDATE requests, SEARCH requests are also recursive. Recipients of the SEARCH requests will send the searched User Record to the sender of request, if they have it in their Address Book, or they will pass the searched request to all users in their Address Book except for the ones which already got the request. The request is going to spread until the recursion depth is achieved.
As a proof-of-concept for this communication protocol, the python library CUP2PY was created. In this library, the Address Books are represented as SQL tables and a dedicated class was created to represent the User Records. The CUP2PY library allows a user to manage their Address Books, create User Records from RSA public/private key pairs and generate the RSA key pairs. The library has the functionality to create and handle UPDATE and SEARCH requests, and convert them from string representations for communication to objects of dedicated classes and back. The library also has the functionality to encrypt and decrypt local files with AES Encryption [5].
The library uses notion of sessions - this allows the user to choose a specific Address Book and User Record, and to create and handle requests using a chosen Address Book and User Record without conflicts.
This tutorial shows how to use CUP2PY to create and manage Address Books and User Records, how to create User Records from RSA public/private key pairs and generate the RSA key pairs. The library has the functionality to create and handle UPDATE and SEARCH requests, and convert them from string representations for communication to objects of dedicated classes and back. The library also has the functionality to encrypt and decrypt local files with AES Encryption.
The library does not contain functions for the actual network communication: sending and receiving the request strings, as well as actual messaging functionality. A different library can be used for that, such as 'socket'.
The class UserRecord has four attributes:
publicKey,
ip,
updateDate,
digitalSignature
For the attribute publicKey class rsa.PublicKey is used. For its string representation the
function publicKeyToString(publicKey) returns .PEM version of the public key. To turn it
back into rsa.PublicKey object the function stringToPublicKey(stringPublicKey) is used.
Attribute ip is stored as a string.
For the attribute updateDate class datetime.datetime is used. For its string representation
format %Y-%m-%d %H:%M:%S.%f is used. To return it back into a datetime.datetime object function
datetime.datetime.strptime(stringUpdateDate, "%Y-%m-%d %H:%M:%S.%f") is used.
Attribute digitalSignature is stored as a bytes object, for its string representation - the
function signatureToB64(signature) returns a Base64 encoded string. To return it back into a
bytes object - the function b64ToSignature(b64Signature) is used.
These attributes are stored as strings in Address Books (SQL tables).
The library uses notion of sessions - this allows the user to choose a specific Address Book and User Record, and to create and handle requests using a chosen Address Book and User Record without conflicts.
To create a new session, we need to use:
import cup2py
s = cup2py.Session()Every session has these attributes:
databaseName,
chosenAddressBook,
chosenUserRecord,
chosenUserRecordPrivateKeyFile
All the attributes are set as None by default.
To set the values for databaseName and chosenAddressBook we will use a dedicated function of the class Session.
The name of Address Book is the name of SQL table which will be used to represent it.
The databaseName can be a direct path to where the database file should be created.
s.setDatabaseName('<databaseName>.db')
s.setAddressBook('<addressBookName>')To create an Address Book (SQL table) with chosen name:
s.createAddressBookInDatabase()Now we have created a database and an Address Book (SQL table) in it.
The library allows users to generate a new RSA keypair and create a new object of the class UserRecord using this keypair
with the function generateUser(localName, ip, path=os.getcwd()).
The keypair will be stored in a current working directory by default, or at the location of the chosen path, with the names
<localName>_publicKey.pem and <localName>_privateKey.pem.
cup2py.generateUser('<localName>', '8.8.8.8')We can also create a new User Record from an already existing RSA keypair.
Here we are loading the keypair files <localName>_publicKey.pem and <localName>_privateKey.pem and creating a new User Record from them with the IP address 8.8.8.8.
priv, pub = cup2py.loadPrivateKey('<localName>'), cup2py.loadPublicKey('<localName>')
user = cup2py.userRecordFromKeyPair(priv, pub, '8.8.8.8')We can also manually generate a new RSA keypair:
generatedPrivateKey, generatedPublicKey = cup2py.generateUserKeyPair()All requests contain a list of hashes of public keys of users, which have already received the request. This is done to minimise unnecessary floods in the network.
To successfully add the public key hash of a local User Record and
route, UPDATE and SEARCH requests we set chosenUserRecord
attribute for the session.
To set a User Record as a default User Record for the session:
s.setUser(someUserRecord)To manually get a required User Record from an Address Book by a public key hash:
user = s.getUserByHash(publicKeyHashValue)or by public key object:
user = s.getUser(publicKey)To update or add a new User Record in the Address Book of the session:
s.updateAddressBookRecord(someUserRecord)This function will update the record, if there already is an older one with the same public key in the Address Book, or add a new one.
This function verifies the digital signature of the record and returns the User Record
if it was updated, or False otherwise.
To create a new UPDATE request:
someUpdateRequest = cup2py.newUpdateRequest(someUserRecord, updateDepth=3)This will generate a new UPDATE request with a given User Record and a 'recursion depth' 3.
To create a new SEARCH request:
someSearchRequest = cup2py.newSearchRequest(searchedPublicKeyHash, senderUserRecord, searchDepth=7)This will generate a new SEARCH request with a given sender's User Record, with searched public key hash and a 'recursion depth' 7.
For transmission purposes, UPDATE and SEARCH requests can be represented in their string form.
Standard form for UPDATE request:
UPDATE;<SenderStringPublicKey>;<SenderIP>;<SenderUpdateDate>;<SenderStringDigitalSignature>;<RecursionDepth>;[<ListOfChechkedHashes>]
Standard form for SEARCH request:
SEARCH;<SearchedPublicKeyHash>;<SenderStringPublicKey>;<SenderIP>;<SenderUpdateDate>;<SenderStringDigitalSignature>;<RecursionDepth>;[<ListOfChechkedHashes>]
To get the string representation for UPDATE requests and the list of IP addresses of users to send those requests to:
someUpdateRequestToSend, someIpList = s.updateToSend(someUpdateRequest)To get the string representation for SEARCH requests and a list of IP addresses of users to send to send those requests to:
someSearchRequestToSend, someIpList = s.searchToSend(someSearchRequest)The functions return tuples in the form (updateRequestToSend, ipList) or (searchRequestToSend, ipList)
where ipList is a list containing all IP addresses stored in the default Address Book, except for the ones listed in request as "checked".
If there is a specific ipList a user wants to use:
anotherUpdateRequestToSend, anotherIpList = s.updateToSend(someUpdateRequest, ipList)anotherSearchRequestToSend, anotherIpList = s.searchToSend(someSearchRequest, ipList)This way a request can be generated even if the chosenAddressBook and databaseName hasn't been defined.
If you just need the string representation for a SEARCH or UPDATE request:
stringRepresentation = someRequest.requestString()To do that:
someRequest = cup2py.getRequest(message)This function identifies the the type of request and returns the request as an object of a suitable class.
To handle and process received UPDATE and SEARCH requests there is the function requestHandler(message).
This function receives a request string, identifies the type of request (UPDATE or SEARCH) using the function getRequest(message) and processes it.
The function UPDATE requests:
- Converts the given string into the object of class
UpdateRecord - Checks the digital signature of the User Record in the request;
- If digital signature is verified, it updates the User Record with the given public key in local Address Book if the received User Record is newer than existing one or adds a new one if there was no User Record with the given public key;
- If
updateDepth > 0, it generates an updated UPDATE request withupdateDepthdecreased by 1 and the hash of thechosenUserRecordof the current session is added tocheckedPublicKeyHashesand it returns a tuple with the string representation of the new request and a list of IP addresses in a form(updateRequestToSend, ipList);
Otherwise, it returns None.
Example:
handledUpdateRequest = s.requestHandler(f"UPDATE;"
f"{stringPublicKey};"
f"{stringIp};"
f"{stringUpdateDate};"
f"{stringDigitalSignature};"
f"{stringUpdateDepth};"
f"[{updatedHash_1},{updatedHash_2},...]")The function SEARCH requests:
- Converts the given string into the object of class
SearchRecord - Checks if
searchDepth > 0 - If it is, generates an updated SEARCH request with
searchDepthdecreased by 1 and the hash ofchosenUserRecordof the current session is added tosearchedPublicKeyHashes - Checks if there is a User Record in
chosenAddressBookwith the givenpublicKeyHash - If there is, it returns a list in a form
[(updateRequestWithFoundUserRecord, [senderIp]), (searchRequestToSend, ipList)], where the first tuple contains an UPDATE request string with the requested User Record and list with the IP of the sender of the SEARCH request and the second tuple contains an updated SEARCH request and list of all IP addresses it should be sent to in case the record in thechosenAddressBookis not the newest available. - If the requested User Record wasn't found, it returns a tuple in the form
(searchRequestToSend, ipList).
Otherwise, it returns None.
Example:
handledSearchRequest = s.requestHandler(f'SEARCH;'
f'{searchedHash};'
f'{stringSenferPublicKey};'
f'{stringSenderIp};'
f'{stringSenderUpdateDate};'
f'{stringSenderDigitalSignature};'
f'{stringSearchDepth};'
f'[{stringCheckedHash_1},{stringCheckedHash_2},...]')Because the function requestHandler can output a list of tuples: for example [(updateRequestWithFoundUserRecord, [senderIp]), (searchRequestToSend, ipList)], a tuple: for example (updateRequest, ipList) or None, the best practice to use it would be:
handlingResult = s.requestHandler(someString)
if handlingResult: # checks if the output is not None
if type(handlingResult) == list: # if there is a list of tuple outputs
someFunction(handlingResult[0])
someFunction(handlingResult[1])
else:
someFunction(handlingResult) # if there is a single tuple outputHere someFunction() takes an UPDATE or SEARCH request with ipList for further processing with the most obvious example being to directly send requests to given IP addresses.
The library also has the functionality to encrypt and decrypt local files with Advanced Encryption Standard (AES) Encryption. To do that, we created another library - AES_module.
Documentation for that library
To encrypt a private key file with the name _privateKey.pem:
cup2py.encryptPrivateKey(localName, password)To decrypt a private key file with the name _privateKey.pem:
cup2py.decryptPrivateKey(localName, password)The password needs to be at least 16 symbols long!