Skip to main content

End-to-End Encryption Implementation

Ryan Dahlberg
Ryan Dahlberg
November 20, 2025 9 min read
Share:
End-to-End Encryption Implementation

Introduction

End-to-End Encryption (E2EE) ensures that only communicating parties can read messages, with no intermediary—not even the service provider—able to access the plaintext data. This fundamental privacy protection has become essential for messaging, file sharing, and collaboration applications.

This comprehensive guide explores implementing E2EE using modern cryptographic protocols, covering key generation, exchange mechanisms, perfect forward secrecy, and secure storage strategies for production applications.

E2EE Architecture

Core Principles

const E2EEPrinciples = {
  zeroKnowledge: 'Server never has access to encryption keys or plaintext',
  endToEnd: 'Only sender and recipient can decrypt messages',
  perfectForwardSecrecy: 'Compromised keys don't affect past communications',
  authentication: 'Verify sender identity cryptographically',
  deniability: 'Messages cannot be cryptographically attributed to sender'
};

// System components
class E2EESystem {
  constructor() {
    this.components = {
      identityKey: 'Long-term user identity (Ed25519)',
      signedPreKey: 'Medium-term signed key (25519)',
      oneTimePreKeys: 'Single-use ephemeral keys',
      session: 'Double Ratchet for message encryption',
      server: 'Untrusted relay for encrypted messages'
    };
  }
}

Signal Protocol Implementation

Key Generation

const sodium = require('libsodium-wrappers');

class E2EEKeyManager {
  async initialize() {
    await sodium.ready;
  }

  // Generate identity key pair (long-term)
  generateIdentityKey() {
    const keyPair = sodium.crypto_sign_keypair();

    return {
      publicKey: sodium.to_base64(keyPair.publicKey),
      privateKey: sodium.to_base64(keyPair.privateKey),
      type: 'identity',
      created: new Date().toISOString()
    };
  }

  // Generate signed pre-key
  generateSignedPreKey(identityPrivateKey) {
    const keyPair = sodium.crypto_box_keypair();

    // Sign the public key with identity key
    const signature = sodium.crypto_sign_detached(
      keyPair.publicKey,
      sodium.from_base64(identityPrivateKey)
    );

    return {
      id: this.generateKeyId(),
      publicKey: sodium.to_base64(keyPair.publicKey),
      privateKey: sodium.to_base64(keyPair.privateKey),
      signature: sodium.to_base64(signature),
      created: new Date().toISOString()
    };
  }

  // Generate one-time pre-keys (batch)
  generateOneTimePreKeys(count = 100) {
    const keys = [];

    for (let i = 0; i < count; i++) {
      const keyPair = sodium.crypto_box_keypair();

      keys.push({
        id: this.generateKeyId(),
        publicKey: sodium.to_base64(keyPair.publicKey),
        privateKey: sodium.to_base64(keyPair.privateKey),
        used: false
      });
    }

    return keys;
  }

  generateKeyId() {
    return sodium.to_base64(sodium.randombytes_buf(16));
  }

  // Verify signed pre-key
  verifySignedPreKey(publicKey, signature, identityPublicKey) {
    return sodium.crypto_sign_verify_detached(
      sodium.from_base64(signature),
      sodium.from_base64(publicKey),
      sodium.from_base64(identityPublicKey)
    );
  }
}

Key Bundle Publishing

class KeyBundleService {
  async publishKeyBundle(userId, keys) {
    const keyBundle = {
      userId,
      identityKey: keys.identityKey.publicKey,
      signedPreKey: {
        id: keys.signedPreKey.id,
        publicKey: keys.signedPreKey.publicKey,
        signature: keys.signedPreKey.signature
      },
      oneTimePreKeys: keys.oneTimePreKeys.map(k => ({
        id: k.id,
        publicKey: k.publicKey
      }))
    };

    // Store on server (encrypted storage)
    await KeyBundle.create(keyBundle);

    return keyBundle;
  }

  async fetchKeyBundle(userId) {
    const bundle = await KeyBundle.findOne({ userId });

    if (!bundle) {
      throw new Error('Key bundle not found');
    }

    // Get one one-time pre-key (if available)
    let oneTimePreKey = null;

    if (bundle.oneTimePreKeys.length > 0) {
      oneTimePreKey = bundle.oneTimePreKeys[0];

      // Remove used one-time pre-key
      await KeyBundle.updateOne(
        { userId },
        { $pull: { oneTimePreKeys: { id: oneTimePreKey.id } } }
      );
    }

    return {
      identityKey: bundle.identityKey,
      signedPreKey: bundle.signedPreKey,
      oneTimePreKey
    };
  }

  async replenishOneTimePreKeys(userId, newKeys) {
    await KeyBundle.updateOne(
      { userId },
      { $push: { oneTimePreKeys: { $each: newKeys } } }
    );
  }
}

Session Establishment

X3DH Key Agreement

class X3DHKeyAgreement {
  constructor() {
    this.keyManager = new E2EEKeyManager();
  }

  async initiateSession(aliceKeys, bobBundle) {
    await sodium.ready;

    // Verify Bob's signed pre-key
    const signatureValid = this.keyManager.verifySignedPreKey(
      bobBundle.signedPreKey.publicKey,
      bobBundle.signedPreKey.signature,
      bobBundle.identityKey
    );

    if (!signatureValid) {
      throw new Error('Invalid signed pre-key signature');
    }

    // Generate ephemeral key
    const ephemeralKey = sodium.crypto_box_keypair();

    // Perform 4-way Diffie-Hellman
    const dh1 = this.dh(
      aliceKeys.identityKey.privateKey,
      bobBundle.signedPreKey.publicKey
    );

    const dh2 = this.dh(
      ephemeralKey.privateKey,
      bobBundle.identityKey
    );

    const dh3 = this.dh(
      ephemeralKey.privateKey,
      bobBundle.signedPreKey.publicKey
    );

    let dh4 = null;
    if (bobBundle.oneTimePreKey) {
      dh4 = this.dh(
        ephemeralKey.privateKey,
        bobBundle.oneTimePreKey.publicKey
      );
    }

    // Derive root key
    const sharedSecrets = [dh1, dh2, dh3];
    if (dh4) sharedSecrets.push(dh4);

    const rootKey = this.deriveRootKey(sharedSecrets);

    return {
      rootKey: sodium.to_base64(rootKey),
      ephemeralPublicKey: sodium.to_base64(ephemeralKey.publicKey),
      usedOneTimePreKeyId: bobBundle.oneTimePreKey?.id
    };
  }

  async acceptSession(bobKeys, ephemeralPublicKey, usedOneTimePreKeyId) {
    await sodium.ready;

    // Perform 4-way DH from Bob's side
    const dh1 = this.dh(
      bobKeys.signedPreKey.privateKey,
      sodium.from_base64(aliceIdentityPublicKey)
    );

    const dh2 = this.dh(
      bobKeys.identityKey.privateKey,
      sodium.from_base64(ephemeralPublicKey)
    );

    const dh3 = this.dh(
      bobKeys.signedPreKey.privateKey,
      sodium.from_base64(ephemeralPublicKey)
    );

    let dh4 = null;
    if (usedOneTimePreKeyId) {
      const oneTimePreKey = bobKeys.oneTimePreKeys.find(
        k => k.id === usedOneTimePreKeyId
      );

      dh4 = this.dh(
        oneTimePreKey.privateKey,
        sodium.from_base64(ephemeralPublicKey)
      );

      // Delete used one-time pre-key
      bobKeys.oneTimePreKeys = bobKeys.oneTimePreKeys.filter(
        k => k.id !== usedOneTimePreKeyId
      );
    }

    const sharedSecrets = [dh1, dh2, dh3];
    if (dh4) sharedSecrets.push(dh4);

    const rootKey = this.deriveRootKey(sharedSecrets);

    return sodium.to_base64(rootKey);
  }

  dh(privateKey, publicKey) {
    return sodium.crypto_scalarmult(
      sodium.from_base64(privateKey),
      sodium.from_base64(publicKey)
    );
  }

  deriveRootKey(sharedSecrets) {
    // Concatenate all shared secrets
    const combined = Buffer.concat(
      sharedSecrets.map(s => Buffer.from(s))
    );

    // Derive root key using HKDF
    return sodium.crypto_kdf_derive_from_key(
      32,
      1,
      'e2ee-root',
      sodium.crypto_generichash(32, combined)
    );
  }
}

Double Ratchet Protocol

Message Encryption with Perfect Forward Secrecy

class DoubleRatchet {
  constructor(rootKey, isInitiator) {
    this.rootKey = rootKey;
    this.sendingChainKey = null;
    this.receivingChainKey = null;
    this.sendMessageNumber = 0;
    this.receiveMessageNumber = 0;
    this.previousSendingChainLength = 0;
    this.skippedMessages = new Map();

    if (isInitiator) {
      this.initializeAsInitiator();
    }
  }

  async initializeAsInitiator() {
    const dhPair = sodium.crypto_box_keypair();
    this.dhSendingKey = dhPair.privateKey;
    this.dhSendingPublicKey = dhPair.publicKey;

    // Derive initial sending chain
    const { rootKey, chainKey } = await this.deriveKeys(
      this.rootKey,
      this.dhSendingKey,
      this.dhReceivingPublicKey
    );

    this.rootKey = rootKey;
    this.sendingChainKey = chainKey;
  }

  async encryptMessage(plaintext) {
    // Derive message key from chain key
    const messageKey = this.deriveMessageKey(this.sendingChainKey);

    // Encrypt message
    const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
    const ciphertext = sodium.crypto_secretbox_easy(
      plaintext,
      nonce,
      messageKey
    );

    // Advance chain key
    this.sendingChainKey = this.deriveNextChainKey(this.sendingChainKey);

    const message = {
      ciphertext: sodium.to_base64(ciphertext),
      nonce: sodium.to_base64(nonce),
      messageNumber: this.sendMessageNumber++,
      dhPublicKey: sodium.to_base64(this.dhSendingPublicKey),
      previousChainLength: this.previousSendingChainLength
    };

    return message;
  }

  async decryptMessage(message) {
    // Check if we need to skip messages
    if (message.messageNumber > this.receiveMessageNumber) {
      await this.skipMessages(
        this.receiveMessageNumber,
        message.messageNumber
      );
    }

    // Check for out-of-order message
    if (message.messageNumber < this.receiveMessageNumber) {
      return this.decryptSkippedMessage(message);
    }

    // Perform DH ratchet if needed
    if (message.dhPublicKey !== this.lastReceivedDHPublicKey) {
      await this.performDHRatchet(message.dhPublicKey);
      this.lastReceivedDHPublicKey = message.dhPublicKey;
    }

    // Derive message key
    const messageKey = this.deriveMessageKey(this.receivingChainKey);

    // Decrypt
    const plaintext = sodium.crypto_secretbox_open_easy(
      sodium.from_base64(message.ciphertext),
      sodium.from_base64(message.nonce),
      messageKey
    );

    // Advance chain
    this.receivingChainKey = this.deriveNextChainKey(this.receivingChainKey);
    this.receiveMessageNumber++;

    return sodium.to_string(plaintext);
  }

  async performDHRatchet(newDHPublicKey) {
    // Save current sending chain
    this.previousSendingChainLength = this.sendMessageNumber;

    // Generate new DH key pair
    const dhPair = sodium.crypto_box_keypair();

    // Derive new root and receiving chain keys
    const { rootKey, chainKey } = await this.deriveKeys(
      this.rootKey,
      this.dhReceivingKey,
      sodium.from_base64(newDHPublicKey)
    );

    this.rootKey = rootKey;
    this.receivingChainKey = chainKey;
    this.receiveMessageNumber = 0;

    // Derive new sending chain
    const { rootKey: newRootKey, chainKey: newChainKey } =
      await this.deriveKeys(
        this.rootKey,
        dhPair.privateKey,
        sodium.from_base64(newDHPublicKey)
      );

    this.rootKey = newRootKey;
    this.sendingChainKey = newChainKey;
    this.sendMessageNumber = 0;

    this.dhSendingKey = dhPair.privateKey;
    this.dhSendingPublicKey = dhPair.publicKey;
  }

  deriveMessageKey(chainKey) {
    return sodium.crypto_kdf_derive_from_key(
      32,
      1,
      'msg-key-',
      chainKey
    );
  }

  deriveNextChainKey(chainKey) {
    return sodium.crypto_kdf_derive_from_key(
      32,
      2,
      'chain-k-',
      chainKey
    );
  }

  async deriveKeys(rootKey, dhPrivate, dhPublic) {
    const dhOutput = sodium.crypto_scalarmult(dhPrivate, dhPublic);

    const kdf = sodium.crypto_kdf_derive_from_key(
      64,
      1,
      'ratchet-',
      sodium.crypto_generichash(32, Buffer.concat([rootKey, dhOutput]))
    );

    return {
      rootKey: kdf.slice(0, 32),
      chainKey: kdf.slice(32, 64)
    };
  }

  async skipMessages(from, to) {
    for (let i = from; i < to; i++) {
      const messageKey = this.deriveMessageKey(this.receivingChainKey);

      this.skippedMessages.set(i, messageKey);
      this.receivingChainKey = this.deriveNextChainKey(this.receivingChainKey);
    }
  }

  decryptSkippedMessage(message) {
    const messageKey = this.skippedMessages.get(message.messageNumber);

    if (!messageKey) {
      throw new Error('Message key not found for out-of-order message');
    }

    const plaintext = sodium.crypto_secretbox_open_easy(
      sodium.from_base64(message.ciphertext),
      sodium.from_base64(message.nonce),
      messageKey
    );

    this.skippedMessages.delete(message.messageNumber);

    return sodium.to_string(plaintext);
  }
}

Complete E2EE Messaging System

Client Implementation

class E2EEMessenger {
  constructor(userId) {
    this.userId = userId;
    this.keyManager = new E2EEKeyManager();
    this.keyAgreement = new X3DHKeyAgreement();
    this.sessions = new Map();
  }

  async initialize() {
    await this.keyManager.initialize();

    // Generate keys if not exists
    const existingKeys = await this.loadKeys();

    if (!existingKeys) {
      await this.generateAndPublishKeys();
    }
  }

  async generateAndPublishKeys() {
    const identityKey = this.keyManager.generateIdentityKey();
    const signedPreKey = this.keyManager.generateSignedPreKey(
      identityKey.privateKey
    );
    const oneTimePreKeys = this.keyManager.generateOneTimePreKeys(100);

    // Store keys locally (encrypted)
    await this.storeKeys({
      identityKey,
      signedPreKey,
      oneTimePreKeys
    });

    // Publish key bundle to server
    const keyBundleService = new KeyBundleService();
    await keyBundleService.publishKeyBundle(this.userId, {
      identityKey,
      signedPreKey,
      oneTimePreKeys
    });
  }

  async sendMessage(recipientId, message) {
    // Get or create session
    let session = this.sessions.get(recipientId);

    if (!session) {
      session = await this.establishSession(recipientId);
      this.sessions.set(recipientId, session);
    }

    // Encrypt message
    const encryptedMessage = await session.ratchet.encryptMessage(message);

    // Send to server
    await this.deliverMessage(recipientId, {
      senderId: this.userId,
      ...encryptedMessage,
      timestamp: new Date().toISOString()
    });

    return encryptedMessage;
  }

  async receiveMessage(senderId, encryptedMessage) {
    // Get or create session
    let session = this.sessions.get(senderId);

    if (!session) {
      session = await this.acceptSession(senderId, encryptedMessage);
      this.sessions.set(senderId, session);
    }

    // Decrypt message
    const plaintext = await session.ratchet.decryptMessage(encryptedMessage);

    return {
      senderId,
      message: plaintext,
      timestamp: encryptedMessage.timestamp
    };
  }

  async establishSession(recipientId) {
    const myKeys = await this.loadKeys();
    const recipientBundle = await new KeyBundleService().fetchKeyBundle(
      recipientId
    );

    const sessionData = await this.keyAgreement.initiateSession(
      myKeys,
      recipientBundle
    );

    const ratchet = new DoubleRatchet(
      sodium.from_base64(sessionData.rootKey),
      true
    );

    return {
      recipientId,
      ratchet,
      established: new Date()
    };
  }

  async storeKeys(keys) {
    // Encrypt keys with device key before storage
    const encrypted = await this.encryptWithDeviceKey(keys);
    await SecureStorage.set(`keys:${this.userId}`, encrypted);
  }

  async loadKeys() {
    const encrypted = await SecureStorage.get(`keys:${this.userId}`);
    return await this.decryptWithDeviceKey(encrypted);
  }
}

Server Implementation

class E2EEMessageServer {
  async deliverMessage(message) {
    // Server only relays encrypted messages
    const { recipientId, senderId, ...encryptedContent } = message;

    // Store encrypted message
    await Message.create({
      recipientId,
      senderId,
      content: encryptedContent,
      delivered: false,
      timestamp: new Date()
    });

    // Attempt real-time delivery
    await this.pushToRecipient(recipientId, message);
  }

  async pushToRecipient(recipientId, message) {
    const connection = this.activeConnections.get(recipientId);

    if (connection) {
      connection.send(JSON.stringify(message));

      await Message.updateOne(
        { _id: message._id },
        { delivered: true, deliveredAt: new Date() }
      );
    }
  }

  async fetchMessages(userId) {
    const messages = await Message.find({
      recipientId: userId,
      delivered: false
    }).sort({ timestamp: 1 });

    return messages.map(m => ({
      senderId: m.senderId,
      content: m.content,
      timestamp: m.timestamp
    }));
  }
}

Group Messaging with E2EE

Sender Keys Protocol

class SenderKeySession {
  constructor() {
    this.chainKey = sodium.randombytes_buf(32);
    this.messageNumber = 0;
  }

  async encryptGroupMessage(message, groupId) {
    const messageKey = this.deriveMessageKey();

    const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
    const ciphertext = sodium.crypto_secretbox_easy(
      message,
      nonce,
      messageKey
    );

    this.advanceChain();

    return {
      groupId,
      ciphertext: sodium.to_base64(ciphertext),
      nonce: sodium.to_base64(nonce),
      messageNumber: this.messageNumber - 1,
      senderId: this.senderId
    };
  }

  deriveMessageKey() {
    return sodium.crypto_kdf_derive_from_key(
      32,
      this.messageNumber,
      'grp-msg-',
      this.chainKey
    );
  }

  advanceChain() {
    this.chainKey = sodium.crypto_generichash(
      32,
      Buffer.concat([this.chainKey, Buffer.from('advance')])
    );
    this.messageNumber++;
  }
}

class GroupE2EE {
  async addMember(groupId, newMemberId) {
    // Send current sender key to new member via 1:1 E2EE
    const senderKeyMessage = {
      groupId,
      chainKey: this.senderKey.chainKey,
      messageNumber: this.senderKey.messageNumber
    };

    await this.messenger.sendMessage(
      newMemberId,
      JSON.stringify(senderKeyMessage)
    );
  }

  async sendGroupMessage(groupId, message) {
    const encrypted = await this.senderKey.encryptGroupMessage(
      message,
      groupId
    );

    // Broadcast to all members
    await this.broadcastToGroup(groupId, encrypted);
  }
}

Conclusion

Implementing End-to-End Encryption provides strong privacy guarantees for users while requiring careful attention to cryptographic details, key management, and protocol implementation. By using proven protocols like Signal and properly implementing the Double Ratchet, applications can achieve security comparable to leading encrypted messaging platforms.

Key takeaways:

  • Use established protocols (Signal, Matrix) rather than custom cryptography
  • Implement perfect forward secrecy with Double Ratchet
  • Properly manage key lifecycle and rotation
  • Support out-of-order and dropped messages
  • Use authenticated encryption (AEAD)
  • Verify message authenticity
  • Implement secure key backup and recovery
  • Support group messaging with Sender Keys
  • Never store plaintext or keys on server
  • Regular security audits are essential

End-to-end encryption is complex but essential for privacy-focused applications. Following proven protocols and best practices ensures security while maintaining usability.

#Encryption #E2EE #Privacy #Cryptography #Messaging