js

How to Build End-to-End Encrypted Chat with Libsodium and the Double Ratchet

Learn how to build end-to-end encrypted chat with Libsodium, X3DH, and Double Ratchet for true message privacy and forward secrecy.

How to Build End-to-End Encrypted Chat with Libsodium and the Double Ratchet

I’ve been thinking a lot about private conversations lately. Every day, we send countless messages, often assuming they are for the eyes of the recipient only. But what about the server in the middle? In my work as a developer, I realized that true privacy means ensuring that no one, not even the platform hosting the chat, can read those messages. This led me down a path to understand and implement real end-to-end encryption, the kind that secures apps used by millions. It’s a fascinating challenge that goes far beyond just enabling HTTPS.

Let’s talk about the difference between transport security and true message privacy. When you use a standard chat app with TLS, your messages are encrypted between you and the server. The server, however, decrypts them. It can read, store, or analyze every word. End-to-end encryption changes this model completely. The server only ever sees scrambled, unreadable ciphertext. The decryption keys exist only on the devices of the people having the conversation. The server becomes a simple, blind messenger.

So, how do we build this? We need a set of reliable cryptographic tools. For this project, I chose libsodium. It provides a robust, high-level API for the essential operations we need. Think of it as a trusted toolbox. First, we need to establish identities. Each user generates a long-term identity key pair. This isn’t used to encrypt every message, but it forms the root of trust, like a digital passport that can be verified.

What happens when two people want to start a private chat for the first time? They need to agree on a shared secret without ever meeting. This is done through a key agreement protocol. A popular and effective method is inspired by the Signal Protocol, called the Extended Triple Diffie-Hellman handshake. It uses a combination of keys to create a single, strong shared secret that only the two participants can calculate.

Each user uploads a “bundle” of public keys to a server. This bundle includes their long-term identity key, a medium-term “signed pre-key,” and a batch of one-time keys. The signature proves the medium-term key genuinely belongs to the owner of the identity key. To start a session, you fetch the other person’s bundle. Using a sequence of Diffie-Hellman calculations between your private keys and their public keys, you derive the same shared secret. The beauty is that this can happen asynchronously.

Let’s look at some initial setup code. First, we install our dependencies and create a utility module to handle cryptographic operations.

npm install libsodium-wrappers express ws ioredis uuid
// src/crypto/utils.ts
import _sodium from 'libsodium-wrappers';
let sodium: typeof _sodium;

export async function initSodium() {
  if (!sodium) {
    await _sodium.ready;
    sodium = _sodium;
  }
  return sodium;
}

export interface KeyPair {
  publicKey: Uint8Array;
  privateKey: Uint8Array;
}

// Generate a key pair for key exchange (X25519 curve)
export async function makeKeyPair(): Promise<KeyPair> {
  const na = await initSodium();
  const pair = na.crypto_kx_keypair();
  return { publicKey: pair.publicKey, privateKey: pair.privateKey };
}

// Sign data with a private key (Ed25519 curve)
export async function createSignature(data: Uint8Array, privateKey: Uint8Array): Promise<Uint8Array> {
  const na = await initSodium();
  return na.crypto_sign_detached(data, privateKey);
}

This gives us the basic functions to create keys and sign data. Next, we model the key bundle a user publishes.

// src/types.ts
export interface PreKeyBundle {
  identityKey: Uint8Array;     // Long-term public identity key
  signedPreKey: {
    key: Uint8Array;          // Medium-term public pre-key
    signature: Uint8Array;    // Proof it's owned by the identity key
  };
  oneTimeKeys: Uint8Array[];  // List of single-use public keys
}

Now, here’s a crucial question: if we use the same secret key for every message, what happens if it gets compromised? An attacker could decrypt everything, past and future. The solution is a concept called “forward secrecy.” We need a way to constantly update our encryption keys. This is where the double ratchet system comes into play.

Imagine two synchronized gears that turn with every message sent and received. This system has two main parts: a Diffie-Hellman ratchet and a symmetric-key ratchet. Every time a new message is sent, the symmetric-key ratchet advances, deriving a new unique message key from the current chain key. This means each message is encrypted with a fresh key. If a single message key is exposed, the others remain safe.

But what about synchronization? The Diffie-Hellman ratchet comes into play when a new session is started or when a new “ratchet key” is exchanged within an ongoing session. This provides “break-in recovery.” Even if an attacker compromises the current set of keys, once the users exchange new ratchet keys, the attacker can no longer decrypt subsequent messages.

The code for managing these ratchet states is intricate. Here’s a simplified look at the core data structure for one side of a conversation.

// src/ratchet/session-state.ts
export interface SessionState {
  // Root key, the foundation for deriving chain keys
  rootKey: Uint8Array;

  // Sending and receiving chains
  sendingChain?: {
    chainKey: Uint8Array;
    index: number;
  };
  receivingChain?: {
    chainKey: Uint8Array;
    index: number;
  };

  // The most recent DH ratchet public keys
  ourRatchetKey: KeyPair;
  theirRatchetKey: Uint8Array;
}

The magic happens in the key derivation function. When sending a message, we “ratchet” the sending chain forward.

// src/ratchet/derive.ts
import { initSodium } from '../crypto/utils';

export async function ratchetChainKey(chainKey: Uint8Array): Promise<{
  newChainKey: Uint8Array;
  messageKey: Uint8Array;
}> {
  const na = await initSodium();
  // Use a hash function to derive the next keys
  const output = na.crypto_generichash(64, chainKey);
  // First 32 bytes become the new chain key, next 32 become the message key
  return {
    newChainKey: output.slice(0, 32),
    messageKey: output.slice(32, 64)
  };
}

So, we have keys that constantly change. How do we actually encrypt and package a message? Each message carries essential metadata so the recipient knows which key to use. We include the index of the message in the ratchet chain and the sender’s current ratchet public key.

Putting it all together, the server’s role is clean and simple. It stores public key bundles and routes encrypted message envelopes. It never sees plaintext or private keys. A message envelope our server handles might look like this JSON.

{
  "to": "user_b",
  "from": "user_a",
  "payload": "encrypted_ciphertext_base64",
  "header": {
    "ratchetKey": "public_key_base64",
    "index": 42,
    "previousChainLength": 10
  }
}

The recipient uses the ratchetKey and index in the header to determine exactly which key in their receiving chain should be used to decrypt the payload. This handles messages arriving out of order.

This architecture provides strong security properties. It ensures that each message has a unique key, that keys are constantly updated, and that the compromise of one key has a limited effect. The server is kept completely unaware of the conversation’s content.

Implementing this correctly is a significant responsibility. It requires careful attention to detail—never reusing a nonce, securely deleting old keys, and properly validating signatures. But the result is worth it: a communication system where users have genuine privacy.

I hope walking through this process shows that while end-to-end encryption is complex, its concepts are approachable. Building it deepens your understanding of how to protect digital conversations in a world where such protection is increasingly vital.

What part of this system do you find most interesting? Would you prioritize forward secrecy or break-in recovery in your design? I’d love to hear your thoughts in the comments. If you found this guide helpful, please consider sharing it with other developers who are curious about building more secure applications.


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Keywords: end-to-end encryption, libsodium, double ratchet, secure chat, forward secrecy



Similar Posts
Blog Image
Complete Guide: Integrating Socket.IO with React for Real-Time Web Applications in 2024

Learn how to integrate Socket.IO with React to build powerful real-time web applications. Master WebSocket connections, live data updates, and seamless user experiences.

Blog Image
How to Build a Scalable Real-time Multiplayer Game with Socket.io Redis and Express

Learn to build scalable real-time multiplayer games with Socket.io, Redis & Express. Covers game state sync, room management, horizontal scaling & deployment best practices.

Blog Image
Build Type-Safe Event-Driven Architecture with TypeScript EventStore NestJS Complete Professional Guide

Learn to build type-safe event-driven architecture with TypeScript, EventStore, and NestJS. Master CQRS, event sourcing, and scalable patterns. Start building now!

Blog Image
Complete Guide to Next.js Prisma Integration: Build Type-Safe Full-Stack Applications in 2024

Learn how to integrate Next.js with Prisma ORM for type-safe full-stack applications. Master database operations, schema management, and seamless API development.

Blog Image
Complete Guide to Building Event-Driven Architecture with Apache Kafka and Node.js

Learn to build scalable event-driven systems with Apache Kafka and Node.js. Complete guide covering setup, type-safe clients, event sourcing, and monitoring. Start building today!

Blog Image
Complete Guide to Next.js Prisma ORM Integration: Build Type-Safe Full-Stack Applications

Learn how to integrate Next.js with Prisma ORM for type-safe full-stack applications. Build robust database-driven apps with seamless TypeScript support. Start today!