Emil Erofeevskiy 1998-As long as possible | All Rights Reserved
Hello, loves.
Let us make the world better than we found it.
I’ve always been fascinated by the idea of staying anonymous. Using Tor, Tails/Whonix to prevent data leaks, I2P networks. I read about PRISM and Snowden’s disclosures, and about those “men in black” supposedly tracking only the bad guys — right?
After a bizarre amount of investigation and education, and several years of enterprise work, I caught myself thinking something obvious: if a lock exists, it could be unlocked.
How many times have we heard about “Anonymity” in the last 10 years? And how many times have systems been breached?
You may say most of the problems come from users and social engineering. And you’re right. But here’s the thing — the risk surface grows along with the complexity of any system.
Nowadays we have another huge problem. We do not own our data. Servers and stakeholders do.
Anonymity is a fiction. Privacy is our right.
They say that Telegram does not provide any data to anybody. And, I guess, that’s true. For now. But they own your keys and messages on their side. Thus, if some inside man (or group) approaches those servers, can we guarantee that data won’t be leaked? Quite a question.
Very important for you to understand, that I’m not saying it is totally bad. We are exchanging our privacy and anonymity for comfort. I actually have a magnificent amount of personal data openly available online. We are not so interesting for being laid eyes on us.
After all, we cannot really hide. Our data is private until it leaves our thoughts and reaches the outer world.
That’s where my journey starts.
I started to investigate the idea of avoiding servers and any third parties. Soon I realised that P2P is the best approach. In that case we would have only ISP between us.
But here is the twist. Our network with IPv4 actively using NAT. As we know, 32-bit addresses are limited. To cope with this, ISPs use NAT to assign a pool of dynamic addresses in clusters. Those clusters may include regions covering several cities. Of course you may buy a “white IP”, a static IPv4 address, but it’s mostly used for server purposes and the price isn’t worth it.
Thus, we can’t connect to each other directly.
The main idea of P2P is that your computer acts as both a client and a server. That means you have to be able not only to send data, but receive it directly on an Internet-facing public address:port.
To bypass NAT, I found Nat-hole-punching.
This is when you use STUN servers to obtain your current public IP and port to use in connections.
The problem is that you’re required to have Cone (Full/Restricted) NAT type, which is not necessarily available.
If you have “Symmetric NAT” (90% of the time), STUN won’t work for you, because each side (external and internal ISP) creates unique mappings. In that case you will have to use TURN relay servers — and at this point it’s no longer true P2P.
STUN — server to obtain your public IP:port.
TURN — server that relays data. Basically, just a regular server.
I tried to do some basic projects via Python, failed and went to contemplate the idea.
If life gives me a challenge (mostly I find it myself) I try to do it nevertheless. Sometimes I cry, sometimes I lose patience and take a break for months. But I do it in the end. Sometimes it is about lack of intelligence, sometimes about negotiation.
What if IPv4 is just old-fashioned and we need to move on? It is fine for servers, but for P2P we have to use something modern and relevant?
Something modern and relevant — pair of tools that are older than me. After all, most “new” things are just good old ideas rediscovered.
Short block “Why?”:
Why C++? Threads, speed, and curiosity. As I told, idea is about running server and client at the same time. And it is better to have control from one UI and instance.
Why IPv6? Because its kajillion-zillion address space makes NAT obsolete. Half of the problem solved: we can connect directly.
So, I understood, that the best approach for me to build the UI wuth the Qt6 Framework which has community edition (IDE) and a fairly good license.
As a part of negotiation I gained an understanding of one important principle. It is not about making Swiss Army knife; it is about make a tool, which will solve concrete problem. Real one. That’s why from now on I demand IPv6 for outgoing and incoming connections.
You may not have it, but it can be connected with ease. For most Eurasian countries it is totally free and mostly preconfigured for personal internet.
Mobile providers, on the other hand, have very bad IPv6 support mostly. At least if you need privacy you have to do something more than just expect comfort. For the latter you may use Telegram and it will be enough.
My job is to make this program as much intuitive and comfortable as possible. Because somehow nobody has done it yet. Didn’t find any.
What rules and demands I used on the way:
Storing data anywhere leads us to increasing risk of data breach.
That’s why we are going to store unencrypted data and keys only in RAM.
One the one hand I wanted to make “history of messages” which can be stored and duplicated locally, but soon I realised that even if I encrypt them with physical password (on paper) it will have one source of truth and potentially can be stolen. Also, your password is your mirror.
So, if a session is dead — your RAM clears it off. Btw, this is also useful as there aren’t many opportunities to steal data from RAM directly.
Encryption should be asynchronous, but fast. I used libsodium and I will tell you my approach more precisely a little bit later.
Client and server run in threads and initiates on demand. Both of them have their own list of connections (TCP sockets).
I’m going to use TCP instead of UDP, because I need full control over socket lifetime and responses and for chat purposes there is no much difference in latency. Actually, because of direct linking speed is abnormally fast.
Users are not idiots. Either they understand what they do, either they should learn a bit. Users may exchange addresses and ports between each other. And they definitely do not need a contact book which also can be leaked. Ha-ha.
Press enter or click to view image in full size
I’ve made a simple UI, which welcomes us with a page containing info about how to run the server and connect to a peer.
There are three languages to choose from: English, Russian (since I know them) and Sindarin. Just ‘cause.
It has two modes separated by a flag:
Public network mode (which will not work and will show you an error message if your network does not provide IPv6).
Local network
For the first approach I use a cURL request to ipify.org. This is the only place where we send an external request (similar to a basic STUN query) to get our public IP. It’s mostly dynamic, but if in your case it is static, you may rebuild it without this request (the code is open).
CPP
Requests *request;
QString externalIP = request->get("https://api64.ipify.org", true);
For this method, I created the Requests class with an uploaded certificate (required for sending TLS HTTPS requests). It is located alongside the .exe files in the root folder.
CPP
Requests::Requests(){
curl = curl_easy_init();
// Set certificate
curl_version_info_data* vinfo = curl_version_info(CURLVERSION_NOW);
if (vinfo && vinfo->ssl_version) {
std::string sslBackend(vinfo->ssl_version);
if (sslBackend.find("OpenSSL") != std::string::npos ||
sslBackend.find("wolfSSL") != std::string::npos ||
sslBackend.find("LibreSSL") != std::string::npos) {
QString certPath = writeCertToTemp();
curl_easy_setopt(curl, CURLOPT_CAINFO, certPath.toUtf8().constData());
} else if (sslBackend.find("Schannel") != std::string::npos) {
// Windows — Schannel no need CA
} else if (sslBackend.find("SecureTransport") != std::string::npos) {
// macOS — no need
}
}
}
As you can see, there are several solutions for different SSL backends. Thanks to the Qt 6 Framework, I don’t have to write those handlers myself. That’s why I chose it.
For the local variant I use parser for my network interfaces, looking up for:
- Windows 11: Ethernet, Wi-fi
- Linux (Ubuntu 22.04): wlo1
- macOS 15: en0
For Mac and Linux those are default ones.
CPP
QString MainWindow::getLocalIPv6Address()
{
const auto interfaces = QNetworkInterface::allInterfaces();
for (const QNetworkInterface& iface : interfaces) {
qDebug() << iface.type();
if (!(iface.flags() & QNetworkInterface::IsUp) ||
!(iface.flags() & QNetworkInterface::IsRunning) ||
(iface.flags() & QNetworkInterface::IsLoopBack)) {
continue;
}
#ifdef Q_OS_WIN
if (
iface.type() != QNetworkInterface::Ethernet &&
iface.type() != QNetworkInterface::Wifi
)
continue;
QString name = iface.humanReadableName();
if (name.contains("vEthernet", Qt::CaseInsensitive) ||
name.contains("VMware", Qt::CaseInsensitive) ||
name.contains("Virtual", Qt::CaseInsensitive) ||
name.contains("TAP", Qt::CaseInsensitive))
continue;
#elif defined(Q_OS_LINUX)
const QString ifname = iface.name(); // "eth0", "enp3s0", "wlp2s0", "lo", "virbr0", "docker0" ...
if (ifname.startsWith("lo") || // loopback
ifname.startsWith("virbr") || // libvirt bridge
ifname.startsWith("docker") || // docker bridge
ifname.startsWith("tun") || // TUN/TAP
ifname.startsWith("tap") ||
ifname.startsWith("veth") || // virtual ethernet
ifname.startsWith("br-") || // docker compose bridge
ifname.startsWith("vmnet") || // VMware
ifname.contains("virtual", Qt::CaseInsensitive))
continue;
#elif defined(Q_OS_MAC)
// en0 is base active interface, en1, en2... are additional for ethernet cabel/thunderbolt connection
if (!iface.name().startsWith("en"))
continue;
#endif
int ifaceIndex = iface.index();
for (const QNetworkAddressEntry& entry : iface.addressEntries()) {
QHostAddress ip = entry.ip();
if (ip.protocol() != QAbstractSocket::IPv6Protocol || ip.isLoopback())
continue;
if (!ip.toString().startsWith("fe80"))
continue;
QString addr = ip.toString().section('%', 0, 0);
#ifdef Q_OS_LINUX
addr += '%' + iface.name();
#else
addr += '%' + QString::number(ifaceIndex);
#endif
return addr;
}
}
return "";
}
After pressing button the“Start server” button and entering the required peer address and port next to “Write to”, then pressing that button, you will be connected to the user (if their server is online, of course).
Simplified connection
Simplified idea of messaging looks like this. In addition you may be connected to another peer and it won’t cause any problems. Program can separately handle those windows. For each connection we have own pair of keys.
For each connection this means: if you are disconnected (closed program for example), you will have to reconnect back alongside peer should connect back to you. They will have message that you disconnected and you message history until closing their window.
To determine which chat page it is, as client ports are dynamic, I get only two IPs and check if their pair persists in the chat list. It helps me to reconnect to the correct view and separate messages between chat windows.
As I mentioned before, ports for client sockets are dynamic. We need this in the case where we have only one server that receives all connections from other users, and the rule for the client is: 1 socket = 1 peer (another user’s server). Thus, we store multiple client connections.
Press enter or click to view image in full size
Peer A with multiple connections
Press enter or click to view image in full size
Dutch wheel scheme
For our messaging we have several rules to prevent DoS attacks.
CPP
void IPv6ChatServer::processMessage(QTcpSocket* socket, QByteArray& buffer)
{
const int maxMessageSize = 64 * 1024;
// We have to ensure, that we get full message and determine client as one for this message
while (buffer.size() >= 4) {
quint32 msgLen = readUInt32(buffer.left(4));
if (buffer.size() < 4 + msgLen) break;
if (msgLen > maxMessageSize) {
qDebug() << "Server: Message too large (" << msgLen << " bytes), dropping.";
socket->disconnectFromHost();
return;
}
QByteArray fullMessage = buffer.mid(4, msgLen);
buffer.remove(0, 4 + msgLen);
int sepIndex = fullMessage.indexOf('\0');
if (sepIndex == -1) continue;
QString clientID = this->updateClientZoneID(QString::fromUtf8(fullMessage.left(sepIndex)));
QByteArray encryptedMessage = fullMessage.mid(sepIndex + 1);
auto session = sessions.value(socket, nullptr);
if (!session) {
qDebug() << "Server: No crypto session found for socket";
continue;
}
qDebug() << "Server: Original message: " << encryptedMessage;
QByteArray message;
try{
message = session->decrypt(encryptedMessage);
} catch (const ICryptoError& ex){
qWarning() << "Server: cannot decrypt message: " << ex.message();
continue;
}
qDebug() << "Server: Received message from: " << clientID << ":" << message;
emit messageArrived(clientID, message);
}
}
To make sure messages are not oversized, we use a simple 4-byte prefix as a delimiter. This marks the transition from raw TCP to our custom protocol.
Before any message is processed, the session must pass a handshake validation. We check whether the handshake is present and whether its length is correct. If something goes wrong, the session is immediately destroyed.
CPP
void IPv6ChatServer::onReadyRead() {
QTcpSocket* senderClient = qobject_cast<QTcpSocket*>(sender());
if (!senderClient) return;
QByteArray& buffer = socketBuffers[senderClient];
buffer.append(senderClient->readAll());
bool isHandshaked = handshakedSockets.contains(senderClient);
if (!isHandshaked && !buffer.startsWith("HANDSHAKE ")){
qDebug() << "Server: Got message before handshake, disconnecting.";
senderClient->disconnectFromHost();
return;
}
// If handshaked, move on
if (!isHandshaked){
const int maxHandshakeLineSize = 2048;
if (buffer.size() > maxHandshakeLineSize) {
qWarning() << "Server: Handshake line too long, disconnecting.";
senderClient->disconnectFromHost();
return;
}
int endIndex = buffer.indexOf('\n');
if (endIndex == -1) return; // Waiting for full line
QByteArray line = buffer.left(endIndex).trimmed();
buffer.remove(0, endIndex + 1);
QString lineStr = QString::fromUtf8(line);
QString peerPublicKeyBase64 = lineStr.section(' ', 1);
QByteArray peerPublicKey = QByteArray::fromBase64(peerPublicKeyBase64.toUtf8());
try{
// Generate keys and session from server side
auto keyPair = cryptoBackend->generateKeyPair();
serverKeys[senderClient] = keyPair;
auto session = cryptoBackend->createSession(*serverKeys[senderClient], peerPublicKey);
sessions[senderClient] = session;
QString serverPublicKeyBase64 = serverKeys[senderClient]->publicKey().toBase64();
QString ackMessage = QString("HANDSHAKE_ACK %1\n").arg(serverPublicKeyBase64);
senderClient->write(ackMessage.toUtf8());
handshakedSockets.insert(senderClient);
} catch (const ICryptoError& ex) {
qWarning() << "Server: Failed handshake:" << ex.message();
senderClient->disconnectFromHost();
}
return;
}
processMessage(senderClient, buffer);
}
The handshake process is flexible — you can modify or extend it as needed. Below is a schema of the current handshake flow:
Press enter or click to view image in full size
As I mentioned, I use libsodium, instead of RSA.
Why not RSA?
RSA is pretty old and slow. Nowadays we are using hybrid:
- Asymmetric only to exchange keys/authentication
- Symmetric for traffic
RSA cons:
RSA-2048/3072 is serveral times slower than ECDH X25519, keys and signatures are bigger: higher latency and size.
Pretty difficult to use.
Cannot be used for chat because of size and speed. Encrypting each message — pretty expensive.
libsodium pros:
X25519 for ECDH (key exchange, new standard).
XChaCha20-Poly1305 for AEAD (fast, secure, big nonces).
Misuse-resistant API. Simpler— less risk surface.
AEAD — Authenticated Encryption with Associated Data, encryption of messages.
You encrypt data and setting an authentication tag (MAC) to ensure that data wasn’t changed (like in JWT, your signature).ECDH — Elliptic Curve Diffie–Hellman, key exchange process.
Works like: you and your peer each have a key pair on elliptic curve — private and public. You use you private key and peer’s public key (peer is mirroring you), count the point on curve. By these manipulations you will have the same shared key, which cannot be derived from the public keys alone.
For example, to derive the keys we:
Press enter or click to view image in full size
Counting shared secret key:
shared_A = ECDH(a_priv, B_pub) # ON PEER A SIDE shared_B = ECDH(b_priv, A_pub) # ON PEER B SIDE Both values are equal: shared_A == shared_B == SHARED_SECRETThen, by applying a KDF (Key Derivation Function), we derive the session keys:
(k_tx, k_rx) = KDF(SHARED_SECRET)k_tx = key for outgoing messages
k_rx = key for incoming messages
Important note: To generate the correct pair of session keys, the cryptographic API requires roles — Client and Server — even though mathematically ECDH is symmetric. Each role uses its own function (
crypto_kx_client_session_keys/crypto_kx_server_session_keysin libsodium).
To decide roles deterministically (without negotiation), we compare the hashes of public keys:
isClient = (hash(selfPub) < hash(peerPub))This way the assignment is automatic, consistent, and cannot be influenced by the network.
So, second round with a new pair of keys works like this:
Press enter or click to view image in full size
k_tx to encrypt and k_rx to decrypt.
CPP
// Client
void IPv6ChatClient::onConnected() {
qDebug() << "Client: Connected to server";
QTcpSocket* socket = qobject_cast<QTcpSocket*>(sender());
if (!socket) return;
QString clientID = socket->property("clientID").toString();
{
QMutexLocker locker(&connectionsMutex);
connections.insert(clientID, {clientID, socket});
}
// Handshake
qDebug() << "Client: Sending handshake";
// Generate pair for this connection
auto keyPair = cryptoBackend->generateKeyPair();
clientKeys.insert(socket, keyPair);
// Encode public key to Base64
QString publicKeyBase64 = clientKeys[socket]->publicKey().toBase64();
// Send handshake message with public key
QString handshakeMessage = QString("HANDSHAKE %1\n").arg(publicKeyBase64);
socket->write(handshakeMessage.toUtf8());
qDebug() << "Client: Sent handshake with public key";
}
// Server
void IPv6ChatServer::onReadyRead() {
...
if (!isHandshaked){
const int maxHandshakeLineSize = 2048;
if (buffer.size() > maxHandshakeLineSize) {
qWarning() << "Server: Handshake line too long, disconnecting.";
senderClient->disconnectFromHost();
return;
}
int endIndex = buffer.indexOf('\n');
if (endIndex == -1) return; // Waiting for full line
QByteArray line = buffer.left(endIndex).trimmed();
buffer.remove(0, endIndex + 1);
QString lineStr = QString::fromUtf8(line);
QString peerPublicKeyBase64 = lineStr.section(' ', 1);
QByteArray peerPublicKey = QByteArray::fromBase64(peerPublicKeyBase64.toUtf8());
try{
// Generate keys and session from server side
auto keyPair = cryptoBackend->generateKeyPair();
serverKeys[senderClient] = keyPair;
auto session = cryptoBackend->createSession(*serverKeys[senderClient], peerPublicKey);
sessions[senderClient] = session;
QString serverPublicKeyBase64 = serverKeys[senderClient]->publicKey().toBase64();
QString ackMessage = QString("HANDSHAKE_ACK %1\n").arg(serverPublicKeyBase64);
senderClient->write(ackMessage.toUtf8());
handshakedSockets.insert(senderClient);
} catch (const ICryptoError& ex) {
qWarning() << "Server: Failed handshake:" << ex.message();
senderClient->disconnectFromHost();
}
return;
}
...
}
In cryptography and computer security, a man-in-the-middle (MITM) attack, or on-path attack, is a cyberattack where the attacker secretly relays and possibly alters the communications between two parties who believe that they are directly communicating with each other, where in actuality the attacker has inserted themselves between the two user parties. — Wikipedia
Our system may be corrupted. If anybody sends you their key during the handshake — that’s the one you will be talking to. And it may be not your familiar.
That’s the point: users shouldn’t act like idiots.
It’s all about privacy and your own sanity:
- You exchange address and port via Telegram for example.
- You connect.
- You double-check by any other type of communication, that you’re really talking to the right person (could be a personal password, like in medieval times).
- Then you can talk about something private.
There is no anonymity here. Your ISP will still know who you’re talking to. But they will never know what you’re talking about — thanks to strong encryption. And since we’re sending tons of data, you’ll just be a drop in the ocean. The same way special forces open-sourced Tor just to hide their own activity.
At this point, I guess I’ve said what I wanted to say. Hope you found it interesting and my program useful. If you run into any issues or have feedback, just let me know on GitHub.
Pages — https://emilianissimo.github.io/Fantom-Chat-P2P-IPv6-Chat/
Github — https://github.com/Emilianissimo/Fantom-Chat-P2P-IPv6-Chat
I’m going to move on with other projects — you will hear about them soon.
For this one, I might eventually add TCP/UDP video and audio calls, but not right now — I’ve got plenty of other interesting things to do.
I encourage you to try it out yourself.
Until then.
Emil Erofeevskiy 1998-As long as possible | All Rights Reserved