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.
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.*