Explore Blogs

Building a decentralized chat application with websockets: Privacy-focused p2p communication

bpi-decentralized-chat-app-with-websocket

Explore how to create a decentralized chat application using WebSockets for peer-to-peer communication, focusing on privacy by preventing the server from knowing which clients are communicating with each other. This guide dives into setting up a signaling server, handling client connections, and implementing secure message relaying to ensure confidentiality.

In today's digital age, privacy is paramount. As developers, we have a responsibility to build applications that respect user privacy and data security. Chat applications, in particular, often handle sensitive information, making privacy a critical concern. But how can we ensure the conversations are private without routing them through central servers?

This article explores how to create a decentralized chat application using WebSockets. We'll design a system where a central server facilitates initial connections but then steps aside, allowing peers to communicate directly. Our primary goal is to prevent the server from knowing which clients are talking to each other, ensuring user privacy.

Understanding the core concepts

Before we dive into the code, let's clarify the core concepts behind our decentralized chat application.

  • Decentralization: Distributing the communication load across multiple clients rather than relying on a central server for all message routing. This enhances privacy and reduces the risk of a single point of failure.
  • WebSockets: A communication protocol that provides full-duplex communication channels over a single TCP connection. WebSockets enable real-time data transfer between a client and a server. In our case, we will use it for initial setup and peer discovery.
  • Signaling Server: A server that assists clients in discovering and establishing peer-to-peer connections. Once the connection is established, the server is no longer involved in the direct communication between peers.
  • Peer-to-Peer (P2P): A network architecture in which devices (peers) share data with each other directly, without needing a central server.

Architecture overview

Our application will consist of the following components:

  • WebSocket server: A Node.js server using the ws library to handle WebSocket connections. This server acts as the signaling server.
  • Client-side application: A JavaScript application running in the browser that connects to the WebSocket server and handles peer-to-peer communication.

Here's a step-by-step overview of how the application works:

  • Client registration: When a client connects to the WebSocket server, it registers itself with a unique ID.
  • Peer discovery: Clients can request a list of available peers from the server. The server provides this list without knowing the intent of any specific connection.
  • Direct connection: Clients use the peer list to establish direct WebSocket connections with each other. The signaling server is no longer involved in message routing.
  • Message relaying: Once a direct connection is established, clients can send messages to each other without the server's involvement.

Setting up the websocket server

First, let's create a Node.js server using the ws library to handle WebSocket connections. This server will manage client registration and provide a list of available peers.

// Import the required modules
const WebSocket = require('ws');
const uuid = require('uuid');

// Create a WebSocket server
const wss = new WebSocket.Server({ port: 8080 });

// Store connected clients
const clients = new Map();

// Handle WebSocket connections
wss.on('connection', (ws) => {
  // Generate a unique ID for the client
  const clientId = uuid.v4();
  clients.set(clientId, ws);

  console.log(`Client ${clientId} connected`);

  // Send the client its ID
  ws.send(JSON.stringify({ type: 'clientId', clientId }));

  // Handle messages from the client
  ws.on('message', (message) => {
    try {
      const parsedMessage = JSON.parse(message);

      switch (parsedMessage.type) {
        case 'getPeers':
          // Send the list of available peers to the client
          const peers = Array.from(clients.keys()).filter((id) => id !== clientId);
          ws.send(JSON.stringify({ type: 'peerList', peers }));
          break;
        case 'relayMessage':
          //Relay message to another client. Server should NOT use it in final version.
          const targetClientId = parsedMessage.target;
          const targetClient = clients.get(targetClientId);
          if (targetClient) {
            targetClient.send(
              JSON.stringify({
                type: 'directMessage',
                sender: clientId,
                content: parsedMessage.content,
              })
            );
          }
          break;
        default:
          console.log(`Received message: ${message}`);
      }
    } catch (error) {
      console.error('Failed to parse message:', error);
    }
  });

  // Handle client disconnection
  ws.on('close', () => {
    clients.delete(clientId);
    console.log(`Client ${clientId} disconnected`);
  });

  // Handle errors
  ws.on('error', (error) => {
    console.error(`WebSocket error: ${error}`);
    clients.delete(clientId);
  });
});

console.log('WebSocket server started on port 8080');

Explanation of the code:

  • We import the ws library and create a new WebSocket server listening on port 8080.
  • We use a Map called clients to store the connected clients, using a unique ID generated by the uuid library.
  • When a client connects, we generate a unique ID, store the client in the clients map, and send the client its ID.
  • We handle messages from the client. If the message type is getPeers, we send the list of available peers to the client.
  • Important: The relayMessage function is present in the above code for the purpose of demonstrating basic message transfer. However, this can be removed to ensure that the server does not actually relay messages between clients.
  • When a client disconnects, we remove it from the clients map.
  • We also handle WebSocket errors and remove the client from the clients map in case of an error.

To run the server, save the code to a file named server.js and run node server.js in your terminal. Make sure you have installed the ws and uuid libraries:

npm install ws uuid

Building the client-side application

Next, let's create the client-side application that connects to the WebSocket server and handles peer-to-peer communication. This application will run in the browser and allow users to send and receive messages.

Create an HTML file (e.g., index.html) with the following content:

<!DOCTYPE html>
<html>
<head>
    <title>Decentralized Chat</title>
</head>
<body>
    <h1>Decentralized Chat</h1>

    <div id="messages"></div>

    <input type="text" id="messageInput" placeholder="Enter your message">
    <button id="sendButton">Send</button>

    <script>
        const ws = new WebSocket('ws://localhost:8080');
        let clientId;
        let peers = [];
        let peerConnections = new Map();

        const messagesDiv = document.getElementById('messages');
        const messageInput = document.getElementById('messageInput');
        const sendButton = document.getElementById('sendButton');

        ws.onopen = () => {
            console.log('Connected to WebSocket server');
        };

        ws.onmessage = event => {
            const message = JSON.parse(event.data);

            switch (message.type) {
                case 'clientId':
                    clientId = message.clientId;
                    console.log(`My client ID is ${clientId}`);
                    break;
                case 'peerList':
                    peers = message.peers;
                    console.log('Available peers:', peers);
                    break;
                case 'directMessage':
                    // Process direct message from another client
                    const messageElem = document.createElement('p');
                    messageElem.textContent = `Received from ${message.sender}: ${message.content}`;
                    messagesDiv.appendChild(messageElem);
                    break;
                default:
                    console.log('Received message:', message);
            }
        };

        ws.onclose = () => {
            console.log('Disconnected from WebSocket server');
            //clean peer connections
            peerConnections.forEach((conn) => {
                conn.close();
            });
            peerConnections.clear();
        };

        ws.onerror = error => {
            console.error('WebSocket error:', error);
        };

        // Function to connect to a peer directly
        function connectToPeer(peerId) {
            const peerWs = new WebSocket('ws://localhost:8080'); // Connect directly to the peer
            peerConnections.set(peerId, peerWs); // Store the connection

            peerWs.onopen = () => {
                console.log(`Connected to peer ${peerId}`);
            };

            peerWs.onmessage = event => {
                const message = JSON.parse(event.data);

                if (message.type === 'directMessage') {
                    // Display the received message
                    const messageElem = document.createElement('p');
                    messageElem.textContent = `Received from ${message.sender}: ${message.content}`;
                    messagesDiv.appendChild(messageElem);
                } else {
                    console.log('Received message from peer:', message);
                }
            };

            peerWs.onclose = () => {
                console.log(`Disconnected from peer ${peerId}`);
                peerConnections.delete(peerId); // Remove the connection
            };

            peerWs.onerror = error => {
                console.error(`WebSocket error connecting to peer ${peerId}:`, error);
                peerConnections.delete(peerId); // Remove the connection
            };
        }

        // Request list of peers and connect to each of them
        function requestPeersAndConnect() {
            ws.send(JSON.stringify({ type: 'getPeers' }));
            setTimeout(() => { // Allow time for server response
                peers.forEach(peerId => {
                    if (!peerConnections.has(peerId) && peerId !== clientId) { // Prevent connecting to self or already connected peers
                        connectToPeer(peerId);
                    }
                });
            }, 500);
        }

        // Send a message to all connected peers
        sendButton.addEventListener('click', () => {
            const messageText = messageInput.value;
            messageInput.value = '';

            peerConnections.forEach((peerWs, peerId) => {
                if (peerWs.readyState === WebSocket.OPEN) {
                    peerWs.send(JSON.stringify({
                        type: 'directMessage',
                        sender: clientId,
                        content: messageText
                    }));

                    // Display the sent message
                    const messageElem = document.createElement('p');
                    messageElem.textContent = `Sent to ${peerId}: ${messageText}`;
                    messagesDiv.appendChild(messageElem);
                } else {
                    console.log(`Peer ${peerId} is not connected. ReadyState: ${peerWs.readyState}`);
                }
            });
        });

        // Request peers and connect every 5 seconds
        setInterval(requestPeersAndConnect, 5000);
    </script>
</body>
</html>

Explanation of the code:

  • We create a new WebSocket connection to the server at ws://localhost:8080.
  • We store the client ID, list of peers, and peer connections in variables.
  • When the connection is opened, we log a message to the console.
  • When we receive a message from the server, we parse the JSON data and handle different message types:
    • clientId: We store the client ID in the clientId variable.
    • peerList: We store the list of available peers in the peers variable.
    • directMessage: We display the direct message from another client in the messages div.
  • When the connection is closed, we log a message to the console.
  • When there is an error, we log the error to the console.
  • connectToPeer(peerId): This function is used to connect directly to another peer. It creates a new WebSocket connection directly to the peer, bypassing the initial signaling server. It handles messages from that peer and displays them.
  • requestPeersAndConnect(): This function sends a request to the signaling server for a list of available peers. After a short delay to allow time for the server to respond, it iterates through the list of peers and attempts to connect to each one, provided it is not already connected and is not the client's own ID.
  • When the "Send" button is clicked, we send the message to all connected peers through direct WebSocket connections.

Open the index.html file in your browser. You should see the "Connected to WebSocket server" message in the console. If you open multiple browser windows, you should see the list of available peers in each window.

Enhancing privacy

While our current implementation prevents the server from knowing the content of messages, it still knows which clients are connecting to the server. To further enhance privacy, we can use techniques like:

  • WebSockets over TLS: Encrypting the WebSocket connection using TLS (Transport Layer Security) to prevent eavesdropping. This is already a standard best practice.
  • Onion Routing: Using a protocol like Tor to hide the client's IP address from the server. This makes it more difficult for the server to identify and track clients.
  • End-to-End Encryption: Encrypting messages on the client-side before sending them to the server, ensuring that only the intended recipient can decrypt the message. This would need to happen regardless of whether the final version of the signaling server relays messages or not.

For the most basic privacy focused method, simply avoid relaying messages in server, and let peers connect each other.

Security considerations

When building a decentralized chat application, it's essential to consider security aspects to protect users from potential threats.

  • Data validation: Always validate data received from clients to prevent injection attacks.
  • Authentication: Implement authentication mechanisms to ensure that only authorized users can access the chat application.
  • Rate limiting: Implement rate limiting to prevent abuse and DoS (Denial of Service) attacks.
  • Regular updates: Keep your dependencies up-to-date to patch any known security vulnerabilities.

Scaling considerations

As your decentralized chat application grows, it's important to consider scaling strategies to handle an increasing number of users.

  • Horizontal scaling: Distribute the load across multiple WebSocket servers to handle more concurrent connections.
  • Load balancing: Use a load balancer to distribute traffic evenly across the WebSocket servers.
  • Connection pooling: Reuse existing WebSocket connections to reduce the overhead of establishing new connections.
  • Optimized data transfer: Compress messages before sending them to reduce bandwidth usage.

Alternative strategies

While WebSockets provide a solid foundation for building real-time applications, other technologies and approaches can be considered for decentralized chat applications.

  • WebRTC data channels: WebRTC (Web Real-Time Communication) provides data channels that allow direct peer-to-peer communication between browsers. This approach eliminates the need for a central server altogether. However, WebRTC requires a more complex signaling mechanism to establish connections.
  • Federated networks: Federated networks like Matrix allow users to communicate across different servers, creating a decentralized communication ecosystem. Matrix uses a standard protocol for exchanging data between servers, allowing for interoperability and decentralization.
  • Blockchain-based solutions: Blockchain technology can be used to build decentralized chat applications with enhanced security and privacy features. Blockchain-based chat applications use a distributed ledger to store messages, making them tamper-proof and censorship-resistant.

Each approach has its own set of trade-offs, and the best choice depends on the specific requirements of your application.

Real-world scenarios

Decentralized chat applications have a wide range of applications in various industries:

  • Secure messaging: Decentralized chat applications can be used to provide secure messaging for journalists, activists, and individuals who need to communicate privately.
  • Collaboration tools: Decentralized chat applications can be integrated into collaboration tools to enable real-time communication and collaboration among team members.
  • Social networking: Decentralized chat applications can be used to build social networking platforms with enhanced privacy and security features.
  • Gaming: Decentralized chat applications can be used to provide in-game communication for multiplayer games.

The possibilities are endless, and decentralized chat applications can empower users with more control over their data and privacy.

Practical tips

Here are some practical tips to keep in mind when building a decentralized chat application:

  • Start simple: Begin with a basic implementation and gradually add features as needed.
  • Focus on user experience: Design a user-friendly interface that is easy to navigate.
  • Test thoroughly: Test your application thoroughly to identify and fix any bugs or issues.
  • Gather feedback: Collect feedback from users and use it to improve your application.
  • Stay informed: Keep up-to-date with the latest trends and technologies in the field of decentralized communication.

By following these tips, you can create a successful decentralized chat application that meets the needs of your users.

Technical aspects

Let's delve into some technical aspects of building a decentralized chat application with WebSockets.

  • WebSocket handshake: The WebSocket handshake is the initial negotiation process between the client and the server. It involves an HTTP upgrade request from the client and a corresponding upgrade response from the server. Understanding the handshake process is crucial for troubleshooting connection issues.
  • WebSocket frames: WebSocket messages are transmitted in frames. Each frame contains a header with information about the message, such as the payload length and the opcode (which indicates the type of data being transmitted). Understanding the WebSocket frame format is essential for implementing custom protocols or extensions.
  • WebSocket compression: WebSocket compression can be used to reduce the size of messages, improving performance and reducing bandwidth usage. The permessage-deflate extension is commonly used for WebSocket compression.
  • WebSocket security: WebSocket connections should be secured using TLS (Transport Layer Security) to prevent eavesdropping. The wss:// scheme is used for secure WebSocket connections.

By understanding these technical aspects, you can build more robust and efficient decentralized chat applications.

This article explored building a decentralized chat application using WebSockets, focusing on privacy and direct peer-to-peer communication. By understanding the core concepts and implementing the necessary components, you can create a secure and private chat experience for your users. By removing relay functionality from server, privacy aspect becomes much more solid.

Stay Updated

This site is protected by reCAPTCHA and the GooglePrivacy Policy andTerms of Service apply.