Back to Blog
Security

Implementing Signal Protocol: A Technical Guide

How we integrated Signal Protocol into Chai.im for end-to-end encrypted messaging. Key exchange, double ratchet, and security considerations.

GrepLabs Team
July 20, 2025
14 min read

End-to-end encryption is essential for private messaging. The Signal Protocol is the gold standard, used by Signal, WhatsApp, and now Chai.im. This article explains how we implemented it and the security guarantees it provides.

Why Signal Protocol?

The Signal Protocol provides:

  • **End-to-end encryption**: Only sender and recipient can read messages
  • **Perfect forward secrecy**: Compromising one key doesn't reveal past messages
  • **Future secrecy**: Compromising one key doesn't reveal future messages
  • **Deniability**: Can't cryptographically prove someone sent a message

No other protocol offers this combination of properties.

Protocol Overview

The Signal Protocol consists of three main components:

  • **X3DH (Extended Triple Diffie-Hellman)**: Initial key exchange
  • **Double Ratchet**: Ongoing message encryption
  • **Sesame**: Multi-device support

Part 1: Key Generation

Each user generates several key pairs:

interface UserKeys {
  // Long-term identity key (never changes)
  identityKey: KeyPair;

  // Signed prekey (rotates periodically)
  signedPrekey: {
    keyPair: KeyPair;
    signature: Uint8Array;  // Signed by identity key
    timestamp: number;
  };

  // One-time prekeys (used once, then discarded)
  oneTimePrekeys: KeyPair[];
}

async function generateUserKeys(): Promise<UserKeys> {
  // Identity key - Curve25519
  const identityKey = await generateKeyPair();

  // Signed prekey - rotates every 30 days
  const signedPrekeyPair = await generateKeyPair();
  const signature = await sign(
    identityKey.privateKey,
    signedPrekeyPair.publicKey
  );

  // Generate 100 one-time prekeys
  const oneTimePrekeys = await Promise.all(
    Array(100).fill(0).map(() => generateKeyPair())
  );

  return {
    identityKey,
    signedPrekey: {
      keyPair: signedPrekeyPair,
      signature,
      timestamp: Date.now()
    },
    oneTimePrekeys
  };
}

Part 2: X3DH Key Exchange

When Alice wants to message Bob for the first time:

// Alice's side - initiating conversation
async function initiateSession(
  aliceIdentity: KeyPair,
  bobBundle: PreKeyBundle
): Promise<SessionState> {
  // Generate ephemeral key for this session
  const ephemeralKey = await generateKeyPair();

  // Verify Bob's signed prekey
  const validSignature = await verify(
    bobBundle.identityKey,
    bobBundle.signedPrekey.signature,
    bobBundle.signedPrekey.publicKey
  );

  if (!validSignature) {
    throw new Error('Invalid signed prekey signature');
  }

  // X3DH: Compute 3-4 DH values
  const dh1 = await dh(aliceIdentity.privateKey, bobBundle.signedPrekey.publicKey);
  const dh2 = await dh(ephemeralKey.privateKey, bobBundle.identityKey);
  const dh3 = await dh(ephemeralKey.privateKey, bobBundle.signedPrekey.publicKey);

  let dh4: Uint8Array | null = null;
  if (bobBundle.oneTimePrekey) {
    dh4 = await dh(ephemeralKey.privateKey, bobBundle.oneTimePrekey);
  }

  // Derive initial root key
  const inputKeyMaterial = concat(dh1, dh2, dh3, dh4 || new Uint8Array());
  const rootKey = await hkdf(inputKeyMaterial, 'X3DH');

  // Initialize double ratchet
  return initializeRatchet({
    rootKey,
    theirIdentityKey: bobBundle.identityKey,
    theirRatchetKey: bobBundle.signedPrekey.publicKey,
    ourEphemeralKey: ephemeralKey
  });
}

Bob's side when receiving the first message:

async function receiveInitialMessage(
  bobIdentity: KeyPair,
  bobSignedPrekey: KeyPair,
  bobOneTimePrekey: KeyPair | null,
  message: InitialMessage
): Promise<SessionState> {
  // X3DH: Compute same DH values
  const dh1 = await dh(bobSignedPrekey.privateKey, message.identityKey);
  const dh2 = await dh(bobIdentity.privateKey, message.ephemeralKey);
  const dh3 = await dh(bobSignedPrekey.privateKey, message.ephemeralKey);

  let dh4: Uint8Array | null = null;
  if (bobOneTimePrekey) {
    dh4 = await dh(bobOneTimePrekey.privateKey, message.ephemeralKey);
    // Delete one-time prekey after use
    deleteOneTimePrekey(bobOneTimePrekey.publicKey);
  }

  // Derive same root key
  const inputKeyMaterial = concat(dh1, dh2, dh3, dh4 || new Uint8Array());
  const rootKey = await hkdf(inputKeyMaterial, 'X3DH');

  return initializeRatchet({
    rootKey,
    theirIdentityKey: message.identityKey,
    theirRatchetKey: message.ephemeralKey,
    ourSignedPrekey: bobSignedPrekey
  });
}

Part 3: Double Ratchet Algorithm

After X3DH establishes the initial keys, the Double Ratchet maintains forward and future secrecy:

interface RatchetState {
  // Root key - evolves with each DH ratchet step
  rootKey: Uint8Array;

  // Sending chain
  sendingChainKey: Uint8Array;
  sendingRatchetKey: KeyPair;
  sendingCounter: number;

  // Receiving chain
  receivingChainKey: Uint8Array;
  theirRatchetKey: Uint8Array;
  receivingCounter: number;

  // Skipped message keys (for out-of-order delivery)
  skippedKeys: Map<string, Uint8Array>;
}

class DoubleRatchet {
  private state: RatchetState;

  async encrypt(plaintext: Uint8Array): Promise<EncryptedMessage> {
    // Derive message key from chain key
    const { messageKey, nextChainKey } = await ratchetChainKey(
      this.state.sendingChainKey
    );

    this.state.sendingChainKey = nextChainKey;

    // Encrypt with AES-256-GCM
    const nonce = generateNonce();
    const ciphertext = await aesGcmEncrypt(messageKey, nonce, plaintext);

    const header: MessageHeader = {
      ratchetKey: this.state.sendingRatchetKey.publicKey,
      counter: this.state.sendingCounter,
      previousCounter: this.state.previousSendingCounter
    };

    this.state.sendingCounter++;

    return { header, nonce, ciphertext };
  }

  async decrypt(message: EncryptedMessage): Promise<Uint8Array> {
    // Check if we need to perform a DH ratchet step
    if (!arrayEquals(message.header.ratchetKey, this.state.theirRatchetKey)) {
      await this.dhRatchet(message.header.ratchetKey);
    }

    // Check for skipped messages
    const skippedKey = this.getSkippedKey(
      message.header.ratchetKey,
      message.header.counter
    );

    if (skippedKey) {
      return aesGcmDecrypt(skippedKey, message.nonce, message.ciphertext);
    }

    // Skip ahead if needed (save keys for later)
    while (this.state.receivingCounter < message.header.counter) {
      const { messageKey, nextChainKey } = await ratchetChainKey(
        this.state.receivingChainKey
      );

      this.saveSkippedKey(
        this.state.theirRatchetKey,
        this.state.receivingCounter,
        messageKey
      );

      this.state.receivingChainKey = nextChainKey;
      this.state.receivingCounter++;
    }

    // Derive message key
    const { messageKey, nextChainKey } = await ratchetChainKey(
      this.state.receivingChainKey
    );

    this.state.receivingChainKey = nextChainKey;
    this.state.receivingCounter++;

    // Decrypt
    return aesGcmDecrypt(messageKey, message.nonce, message.ciphertext);
  }

  private async dhRatchet(theirNewRatchetKey: Uint8Array) {
    // Save skipped keys from old chain
    // ... (implementation detail)

    // Update receiving chain
    this.state.theirRatchetKey = theirNewRatchetKey;
    const dhOutput = await dh(
      this.state.sendingRatchetKey.privateKey,
      theirNewRatchetKey
    );

    const { rootKey, chainKey } = await kdfRootKey(
      this.state.rootKey,
      dhOutput
    );

    this.state.rootKey = rootKey;
    this.state.receivingChainKey = chainKey;
    this.state.receivingCounter = 0;

    // Generate new sending ratchet key
    this.state.sendingRatchetKey = await generateKeyPair();

    const dhOutput2 = await dh(
      this.state.sendingRatchetKey.privateKey,
      theirNewRatchetKey
    );

    const { rootKey: newRootKey, chainKey: sendingChainKey } = await kdfRootKey(
      this.state.rootKey,
      dhOutput2
    );

    this.state.rootKey = newRootKey;
    this.state.sendingChainKey = sendingChainKey;
    this.state.sendingCounter = 0;
  }
}

Part 4: Key Derivation Functions

The protocol uses HKDF extensively:

async function hkdf(
  inputKeyMaterial: Uint8Array,
  info: string,
  length: number = 32
): Promise<Uint8Array> {
  const key = await crypto.subtle.importKey(
    'raw',
    inputKeyMaterial,
    { name: 'HKDF' },
    false,
    ['deriveBits']
  );

  const bits = await crypto.subtle.deriveBits(
    {
      name: 'HKDF',
      salt: new Uint8Array(32),
      info: new TextEncoder().encode(info),
      hash: 'SHA-256'
    },
    key,
    length * 8
  );

  return new Uint8Array(bits);
}

async function kdfRootKey(
  rootKey: Uint8Array,
  dhOutput: Uint8Array
): Promise<{ rootKey: Uint8Array; chainKey: Uint8Array }> {
  const derived = await hkdf(
    concat(rootKey, dhOutput),
    'RootKeyDerivation',
    64
  );

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

async function ratchetChainKey(
  chainKey: Uint8Array
): Promise<{ messageKey: Uint8Array; nextChainKey: Uint8Array }> {
  const messageKey = await hmac(chainKey, new Uint8Array([0x01]));
  const nextChainKey = await hmac(chainKey, new Uint8Array([0x02]));

  return { messageKey, nextChainKey };
}

Part 5: Message Format

Our wire format for encrypted messages:

interface EncryptedMessage {
  // Protocol version
  version: number;  // Currently 3

  // Header (not encrypted, but authenticated)
  header: {
    // Sender's current ratchet public key
    ratchetKey: Uint8Array;  // 32 bytes

    // Message counters
    counter: number;
    previousCounter: number;
  };

  // Nonce for AES-GCM
  nonce: Uint8Array;  // 12 bytes

  // Ciphertext (includes GCM auth tag)
  ciphertext: Uint8Array;
}

// Serialization
function serializeMessage(msg: EncryptedMessage): Uint8Array {
  const buffer = new ArrayBuffer(
    1 +      // version
    32 +     // ratchet key
    4 +      // counter
    4 +      // previous counter
    12 +     // nonce
    msg.ciphertext.length
  );

  const view = new DataView(buffer);
  let offset = 0;

  view.setUint8(offset++, msg.version);
  new Uint8Array(buffer, offset, 32).set(msg.header.ratchetKey);
  offset += 32;
  view.setUint32(offset, msg.header.counter, false);
  offset += 4;
  view.setUint32(offset, msg.header.previousCounter, false);
  offset += 4;
  new Uint8Array(buffer, offset, 12).set(msg.nonce);
  offset += 12;
  new Uint8Array(buffer, offset).set(msg.ciphertext);

  return new Uint8Array(buffer);
}

Security Considerations

Key Storage

Private keys must be protected:

// Use platform-specific secure storage
class SecureKeyStore {
  async store(keyId: string, key: Uint8Array): Promise<void> {
    if (typeof window !== 'undefined') {
      // Browser: Use IndexedDB with origin-bound encryption
      await this.browserStore(keyId, key);
    } else if (process.platform === 'darwin') {
      // macOS: Use Keychain
      await this.keychainStore(keyId, key);
    } else if (process.platform === 'win32') {
      // Windows: Use DPAPI
      await this.dpapiStore(keyId, key);
    } else {
      // Linux: Use secret-service or encrypted file
      await this.secretServiceStore(keyId, key);
    }
  }

  async retrieve(keyId: string): Promise<Uint8Array | null> {
    // Platform-specific retrieval
  }
}

Session State Persistence

Sessions must survive app restarts:

interface PersistedSession {
  recipientId: string;
  state: RatchetState;
  createdAt: number;
  lastUsed: number;
}

class SessionStore {
  private db: IDBDatabase;

  async saveSession(session: PersistedSession): Promise<void> {
    // Encrypt session state before storage
    const encrypted = await this.encryptState(session.state);

    const tx = this.db.transaction('sessions', 'readwrite');
    await tx.objectStore('sessions').put({
      recipientId: session.recipientId,
      encryptedState: encrypted,
      createdAt: session.createdAt,
      lastUsed: Date.now()
    });
  }
}

Handling Compromised Keys

If a device is compromised:

async function rotateIdentityKey(userId: string): Promise<void> {
  // 1. Generate new identity key
  const newIdentity = await generateKeyPair();

  // 2. Sign new identity with old identity (if available)
  // This creates a verifiable key rotation chain

  // 3. Upload new prekey bundle
  await uploadPreKeyBundle(userId, newIdentity);

  // 4. Notify contacts of key change
  // They'll see "Security code changed" notification

  // 5. Establish new sessions with all contacts
  await resetAllSessions(userId);
}

Safety Numbers

Users can verify they're talking to the right person:

function generateSafetyNumber(
  ourIdentityKey: Uint8Array,
  theirIdentityKey: Uint8Array
): string {
  // Concatenate keys in consistent order
  const combined = ourIdentityKey < theirIdentityKey
    ? concat(ourIdentityKey, theirIdentityKey)
    : concat(theirIdentityKey, ourIdentityKey);

  // Hash and format as numbers
  const hash = sha512(combined);

  // Convert to 60-digit number (5 groups of 12)
  let result = '';
  for (let i = 0; i < 30; i += 6) {
    const num = (hash[i] << 40) | (hash[i+1] << 32) |
                (hash[i+2] << 24) | (hash[i+3] << 16) |
                (hash[i+4] << 8) | hash[i+5];
    result += num.toString().padStart(12, '0') + ' ';
  }

  return result.trim();
}

Testing

Encryption implementations require thorough testing:

describe('Signal Protocol', () => {
  it('should establish session via X3DH', async () => {
    const alice = await generateUserKeys();
    const bob = await generateUserKeys();

    // Alice initiates
    const aliceSession = await initiateSession(
      alice.identityKey,
      bob.getPreKeyBundle()
    );

    // Alice sends first message
    const encrypted = await aliceSession.encrypt(
      new TextEncoder().encode('Hello Bob!')
    );

    // Bob receives and establishes session
    const bobSession = await receiveInitialMessage(
      bob.identityKey,
      bob.signedPrekey.keyPair,
      bob.oneTimePrekeys[0],
      encrypted
    );

    const decrypted = await bobSession.decrypt(encrypted);
    expect(new TextDecoder().decode(decrypted)).toBe('Hello Bob!');
  });

  it('should maintain forward secrecy', async () => {
    // ... test that compromising current key doesn't reveal past messages
  });

  it('should handle out-of-order messages', async () => {
    // ... test message reordering
  });
});

Conclusion

Implementing Signal Protocol correctly is challenging but essential for truly private messaging. The protocol provides strong security guarantees through careful cryptographic design.

Key takeaways:

  • X3DH establishes initial shared secret asynchronously
  • Double Ratchet provides forward and future secrecy
  • Every message uses a unique encryption key
  • Proper key storage is as important as the protocol itself

*Experience Signal Protocol encryption with Chai.im.*

Tags
EncryptionSecuritySignal ProtocolMessaging