The Lord in Aberdeenshire, Inventor and fine lad

On behalf of Established Titles



Interlude

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.

First attempts

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?

IPv6 and C++

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?”:

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:

Main structure

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:

  1. Public network mode (which will not work and will show you an error message if your network does not provide IPv6).

  2. 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).

Logic of messaging

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.

// Unique Identificator for chat list (contact list) inline QString makeChatID(const QString& a, const QString& b){ QString a_clean = a.section('%', 0, 0); QString b_clean = b.section('%', 0, 0); return (a_clean < b_clean) ? (a_clean + "__" + b_clean) : (b_clean + "__" + a_clean); }

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

Security and encryption

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:

libsodium pros:

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_SECRET

Then, by applying a KDF (Key Derivation Function), we derive the session keys:

(k_tx, k_rx) = KDF(SHARED_SECRET)

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_keys in 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.

Code

  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;
    }
...
}

MITM problem

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.

Links

Pages — https://emilianissimo.github.io/Fantom-Chat-P2P-IPv6-Chat/
Github — https://github.com/Emilianissimo/Fantom-Chat-P2P-IPv6-Chat

Plans

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.