Adapting the Signal Protocol for P2P Messaging
⚠️ WARNING: This document is not finished. The details in this document are subject to change.
The Signal Protocol has become the gold standard for end-to-end encrypted messaging, powering applications like WhatsApp, Signal, and Facebook Messenger. But what happens when you want to implement the same level of security in a truly peer-to-peer environment—one without centralized servers managing pre-keys and message routing?
In this article, we'll explore how to adapt the Signal Protocol's X3DH (Extended Triple Diffie-Hellman) key agreement and Double Ratchet algorithm for direct peer-to-peer communication over WebRTC. We'll discuss the challenges unique to P2P environments, propose practical solutions, and walk through a browser-based JavaScript implementation that maintains the security guarantees of the original protocol.
Introduction to Signal Protocol
The Signal Protocol consists of two main cryptographic components working together to provide secure, private messaging with forward secrecy and break-in recovery:
X3DH: Extended Triple Diffie-Hellman
X3DH is the key agreement protocol that establishes a shared secret between two parties who mutually authenticate each other based on public keys. In the traditional Signal Protocol, it works like this:
The key insight of X3DH is that it combines four Diffie-Hellman operations:
- DH1:
Alice_Identity × Bob_SignedPrekey
- DH2:
Alice_Ephemeral × Bob_Identity
- DH3:
Alice_Ephemeral × Bob_SignedPrekey
- DH4:
Alice_Ephemeral × Bob_OneTimePrekey
(if available)
These are concatenated and run through HKDF (HMAC-based Key Derivation Function) to produce the initial shared secret. This provides:
- Mutual authentication (both parties prove their identity)
- Forward secrecy (ephemeral keys protect future messages)
- Cryptographic deniability (no one can prove who sent what)
Double Ratchet: Forward Secrecy for Every Message
Once the initial shared secret is established via X3DH, the Double Ratchet algorithm takes over for ongoing message encryption. It provides:
Symmetric-key ratchet (Chain Keys)
- Each message derives a new encryption key from the current chain key
- Chain keys are "ratcheted forward" after each use (old keys deleted)
- Provides forward secrecy within a single sending/receiving direction
Diffie-Hellman ratchet (Root Keys)
- Each time a party sends a message, they generate a new DH key pair
- When receiving a new DH public key, perform a new DH computation
- Update the root key and derive new sending/receiving chain keys
- Provides break-in recovery (compromise of one key doesn't affect future messages)
Why is this important?
- Forward Secrecy: If an attacker compromises your device today, they can't decrypt messages from yesterday
- Break-in Recovery: If an attacker compromises your keys and you send a new message, the session "heals" and future messages are secure again
- Out-of-order delivery: Messages can arrive out of order and still be decrypted correctly
The Traditional Architecture
In the standard Signal Protocol implementation, a central server plays a crucial role:
The server:
- Stores pre-key bundles uploaded by users
- Distributes pre-keys to those wanting to initiate conversations
- Relays encrypted messages between parties who may not be online simultaneously
- Manages one-time prekeys (consumes them after use)
This architecture enables asynchronous messaging—Alice can send a message to Bob even if Bob is offline. But it also introduces a central point of trust and potential failure.
Challenges in P2P Environments
Moving from a server-mediated architecture to pure peer-to-peer introduces fundamental challenges that require rethinking parts of the Signal Protocol:
The Pre-key Problem
In traditional Signal, Bob uploads pre-key bundles to a server before Alice wants to talk to him. When Alice initiates a conversation:
- She fetches Bob's pre-key bundle from the server
- She performs the X3DH computation locally
- She sends the initial encrypted message asynchronously (Bob doesn't need to be online)
In P2P, this breaks down:
- ❌ No central server to store pre-key bundles
- ❌ Both parties must be online simultaneously to exchange keys
- ❌ No asynchronous messaging (can't send to offline peers)
Timing and Connection Constraints
In P2P over WebRTC:
- Both peers must be online to establish the initial connection
- Connection is ephemeral - if either peer disconnects, the connection is lost
- No message queue - messages can only be delivered when both parties are connected
- Session resumption requires re-establishing the WebRTC connection
Comparison: Server-Based vs P2P
Aspect | Server-Based Signal | P2P Signal |
---|---|---|
Pre-key Storage | Server stores bundles | No storage - live exchange |
Asynchronous Messaging | ✅ Yes (via server queue) | ❌ No - both must be online |
Initial Handshake | Fetch from server | Real-time negotiation via WebRTC |
One-Time Prekeys | Server manages, consumes after use | Not practical in P2P |
Connection Type | Client → Server → Client | Direct Client ↔ Client |
Offline Messages | ✅ Stored by server | ❌ Not possible |
NAT Traversal | Server as relay | Requires STUN/TURN servers |
Session Persistence | Server-side state | Client-side only |
What Remains the Same?
Despite these challenges, the core cryptographic primitives work identically:
✅ X25519 for Diffie-Hellman key agreement ✅ Ed25519 for digital signatures ✅ HKDF for key derivation ✅ AES-GCM for authenticated encryption ✅ Double Ratchet algorithm (unchanged)
The difference is when and how keys are exchanged, not the cryptographic operations themselves.
The P2P Advantage
While P2P introduces constraints, it also offers benefits:
✅ True Decentralization
- No servers to trust, compromise, or shut down
- Users control their own data and connections
✅ Privacy
- No server can see who is talking to whom
- No metadata collection by third parties
✅ Resilience
- No single point of failure
- Cannot be easily censored or blocked
✅ Reduced Infrastructure Costs
- No need to run and maintain servers
- Scalability is distributed across peers
The key question becomes: How do we adapt X3DH to work in real-time over a WebRTC connection while maintaining the same security properties?
Adapting X3DH for P2P
The solution is to perform X3DH as a synchronous, real-time handshake over an already-established WebRTC data channel. Here's how:
P2P X3DH Protocol Flow
Instead of pre-uploading keys to a server, both Alice and Bob exchange keys in real-time once their WebRTC connection is established:
Key Differences from Traditional X3DH
Traditional X3DH | P2P X3DH |
---|---|
4 DH operations (with one-time prekey) | 3 DH operations (no one-time prekey) |
Asynchronous (fetch bundle, compute later) | Synchronous (both online, real-time exchange) |
One-time prekeys managed by server | No one-time prekeys |
Signature verification before first message | Signature verification during handshake |
Why Skip One-Time Prekeys?
In traditional Signal, one-time prekeys provide an additional layer of forward secrecy for the very first message before the Double Ratchet kicks in. They are:
- Generated in bulk (100 at a time)
- Uploaded to the server
- Consumed once and deleted
- Replenished when running low
In P2P, one-time prekeys are impractical:
- No server to store and manage them
- Both parties are online, so Double Ratchet starts immediately anyway
- The ephemeral key in DH2 and DH3 already provides forward secrecy
- After the first message exchange, Double Ratchet takes over
Security Trade-off:
- ✅ Still get forward secrecy from ephemeral keys
- ✅ Mutual authentication via identity keys
- ✅ Signature verification prevents MITM
- ✅ WebRTC DTLS provides base layer encryption with forward secrecy
- ⚠️ Application-layer: 3-DH instead of 4-DH (but on top of WebRTC's existing FS)
- ✅ Immediate Double Ratchet initialization provides ongoing forward secrecy
- 📝 Defense in depth: Multiple layers of encryption (WebRTC + Signal Protocol)
Simplified P2P X3DH Implementation
Here's pseudocode for the P2P variant:
// Reference: demonstrateSignalProtocol() in cryptography module
async function p2pKeyExchange(webrtcDataChannel) {
// 1. Generate long-term identity keys (X25519 + Ed25519)
const identityKeyPair = await generateSignalKeyPair(); // X25519
const identitySigningKeyPair = await generateSignalSigningKeyPair(); // Ed25519
// 2. Generate signed prekey
const signedPrekeyPair = await generateSignalKeyPair();
const prekeyBytes = await exportSignalPublicKey(signedPrekeyPair.publicKey);
const signature = await signSignalData(identitySigningKeyPair.privateKey, prekeyBytes);
// 3. Create public key bundle
const myBundle = {
identityKey: await exportSignalPublicKey(identityKeyPair.publicKey),
identitySigningKey: await exportSignalPublicKey(identitySigningKeyPair.publicKey),
signedPrekey: prekeyBytes,
signature: signature
};
// 4. Exchange bundles over WebRTC
webrtcDataChannel.send(JSON.stringify(myBundle));
const peerBundle = await waitForPeerBundle(webrtcDataChannel);
// 5. Verify peer's signature
const peerSigningKey = await importSignalSigningPublicKey(peerBundle.identitySigningKey);
const isValid = await verifySignalSignature(
peerSigningKey,
peerBundle.signature,
peerBundle.signedPrekey
);
if (!isValid) {
throw new Error("Invalid signature - possible MITM attack!");
}
// 6. Perform 3-DH key agreement
const ephemeralKeyPair = await generateSignalKeyPair();
const peerIdentityKey = await importSignalPublicKey(peerBundle.identityKey);
const peerSignedPrekey = await importSignalPublicKey(peerBundle.signedPrekey);
const dh1 = await performSignalDH(identityKeyPair.privateKey, peerSignedPrekey);
const dh2 = await performSignalDH(ephemeralKeyPair.privateKey, peerIdentityKey);
const dh3 = await performSignalDH(ephemeralKeyPair.privateKey, peerSignedPrekey);
// 7. Concatenate DH outputs and derive shared secret
const dhOutput = concatSignalArrayBuffers(dh1, dh2, dh3);
const sharedSecret = await deriveSignalKey(
dhOutput,
new ArrayBuffer(32), // salt
new TextEncoder().encode("P2P_Signal_X3DH")
);
// 8. Exchange ephemeral public keys
const myEphemeralPublic = await exportSignalPublicKey(ephemeralKeyPair.publicKey);
webrtcDataChannel.send(myEphemeralPublic);
// 9. Return shared secret for Double Ratchet initialization
return {
sharedSecret: await crypto.subtle.exportKey("raw", sharedSecret),
myEphemeralPublic,
peerIdentityKey
};
}
Security Analysis
What we preserve from Signal: ✅ Mutual authentication - Both parties prove their identity via long-term identity keys ✅ Forward secrecy - Ephemeral keys protect the session ✅ Signature verification - Ed25519 signatures prevent impersonation ✅ Deniability - No non-repudiable proof of who sent what
What changes: ⚠️ Synchronous requirement - Both parties must be online ⚠️ 3-DH instead of 4-DH - One fewer DH operation at application layer ✅ Immediate Double Ratchet - Provides ongoing forward secrecy from first message ✅ Defense in depth - WebRTC's DTLS layer already provides transport encryption + FS
Attack resistance:
- ❌ Passive eavesdropping - Impossible (WebRTC DTLS + Signal Protocol double encryption)
- ❌ MITM attacks - Prevented by signature verification at application layer
- ❌ Replay attacks - Prevented by ephemeral keys + message counters
- ⚠️ Compromise of long-term keys - Affects future sessions (use key rotation)
Layered Security: The P2P implementation benefits from defense in depth:
- Transport layer: WebRTC's DTLS with forward secrecy
- Application layer: Signal Protocol (X3DH + Double Ratchet)
- Result: Even if one layer is compromised, the other protects the communication
Comparison: Traditional vs P2P X3DH
Traditional X3DH Flow
P2P X3DH Flow
The P2P variant sacrifices asynchronous messaging and one-time prekeys, but maintains the core security properties while being implementable entirely in the browser using the WebCrypto API.
Implementing Double Ratchet in P2P
The good news: The Double Ratchet algorithm works identically in P2P as it does in server-based Signal. No modifications needed! Once you have the shared secret from X3DH, you initialize the ratchet state and start exchanging encrypted messages.
Double Ratchet State Machine
The Double Ratchet maintains state for both sending and receiving chains:
Core Ratchet State
Each participant maintains:
// Reference: initializeDoubleRatchet() in cryptography module
const ratchetState = {
// Root key - updated during DH ratchet steps
rootKey: ArrayBuffer,
// Chain keys - updated after each message
sendingChainKey: ArrayBuffer,
receivingChainKey: ArrayBuffer,
// DH key pairs for ratcheting
sendingDHKeyPair: CryptoKeyPair,
receivingDHPublicKey: CryptoKey,
// Message counters
sendingMessageNumber: 0,
receivingMessageNumber: 0,
previousChainLength: 0,
// Skipped message keys (for out-of-order delivery)
skippedMessageKeys: Map<string, ArrayBuffer>
};
Symmetric Key Ratchet
The symmetric-key ratchet derives unique message keys from chain keys:
// Reference: deriveMessageKey(), deriveNextChainKey() in cryptography module
async function symmetricRatchet(chainKey) {
// Derive message key for current message
const messageKey = await HMAC_SHA256(chainKey, 0x01);
// Derive next chain key
const nextChainKey = await HMAC_SHA256(chainKey, 0x02);
// Delete old chain key (forward secrecy!)
chainKey.fill(0);
return { messageKey, nextChainKey };
}
Each message gets a unique key derived from the chain, then the chain "ratchets forward" and the old key is securely erased.
DH Ratchet Step
When receiving a new DH public key from the peer, perform a DH ratchet:
// Reference: performDHRatchetStep() in cryptography module
async function dhRatchetStep(state, newRemoteDHPublicKey) {
// 1. Derive receiving chain from DH operation
const dhOutput = await ECDH(state.sendingDHKeyPair.privateKey, newRemoteDHPublicKey);
const hkdfOutput = await HKDF(state.rootKey, dhOutput, "DoubleRatchet_RootKey");
state.rootKey = hkdfOutput.slice(0, 32);
state.receivingChainKey = hkdfOutput.slice(32, 64);
state.receivingMessageNumber = 0;
// 2. Generate new DH key pair for sending
state.sendingDHKeyPair = await generateSignalKeyPair();
// 3. Derive sending chain from new DH operation
const dhOutput2 = await ECDH(state.sendingDHKeyPair.privateKey, newRemoteDHPublicKey);
const hkdfOutput2 = await HKDF(state.rootKey, dhOutput2, "DoubleRatchet_RootKey");
state.rootKey = hkdfOutput2.slice(0, 32);
state.sendingChainKey = hkdfOutput2.slice(32, 64);
state.sendingMessageNumber = 0;
}
This provides break-in recovery: even if an attacker steals the current keys, the next DH ratchet generates completely new keys.
Message Encryption Flow
Each message envelope includes:
- DH public key (for DH ratchet detection)
- Message number (for out-of-order handling)
- Ciphertext (AES-GCM encrypted)
- IV (initialization vector for AES-GCM)
Message Decryption Flow
Handling Out-of-Order Messages
Because P2P connections can experience packet loss or reordering, the Double Ratchet handles this elegantly:
// Reference: skipMessageKeys(), doubleRatchetDecrypt() in cryptography module
async function handleOutOfOrder(state, messageNumber) {
// If message number > current, we skipped some messages
if (messageNumber > state.receivingMessageNumber) {
const skipped = messageNumber - state.receivingMessageNumber;
if (skipped > MAX_SKIP) {
throw new Error("Too many skipped messages - possible attack");
}
// Derive and store skipped message keys
let chainKey = state.receivingChainKey;
for (let i = 0; i < skipped; i++) {
const messageKey = await deriveMessageKey(chainKey);
const keyId = `${dhPublicKeyHex}:${state.receivingMessageNumber + i}`;
state.skippedMessageKeys.set(keyId, messageKey);
chainKey = await deriveNextChainKey(chainKey);
}
state.receivingChainKey = chainKey;
state.receivingMessageNumber = messageNumber;
}
}
When a skipped message arrives later, its key is retrieved from storage and used to decrypt.
Simplified P2P Double Ratchet Implementation
// Reference: demonstrateDoubleRatchet() in cryptography module
async function doubleRatchetEncrypt(state, plaintext) {
// 1. Derive message key
const { messageKey, nextChainKey } = await symmetricRatchet(state.sendingChainKey);
state.sendingChainKey = nextChainKey;
// 2. Prepare authenticated additional data (AAD)
const dhPublicKey = await exportSignalPublicKey(state.sendingDHKeyPair.publicKey);
const aad = new Uint8Array(dhPublicKey.length + 8);
aad.set(dhPublicKey);
new DataView(aad.buffer, dhPublicKey.length).setUint32(0, state.sendingMessageNumber);
// 3. Encrypt with AES-GCM
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv, additionalData: aad },
await importAESKey(messageKey),
new TextEncoder().encode(plaintext)
);
// 4. Securely delete message key
messageKey.fill(0);
// 5. Increment counter and return envelope
const envelope = {
dhPublicKey,
messageNumber: state.sendingMessageNumber++,
ciphertext: new Uint8Array(ciphertext),
iv
};
return envelope;
}
async function doubleRatchetDecrypt(state, envelope) {
// 1. Check if DH ratchet is needed
const currentDHKey = await exportSignalPublicKey(state.receivingDHPublicKey);
if (!arraysEqual(envelope.dhPublicKey, currentDHKey)) {
const newDHPublicKey = await importSignalPublicKey(envelope.dhPublicKey);
await dhRatchetStep(state, newDHPublicKey);
}
// 2. Handle out-of-order messages
await handleOutOfOrder(state, envelope.messageNumber);
// 3. Derive message key
const { messageKey, nextChainKey } = await symmetricRatchet(state.receivingChainKey);
state.receivingChainKey = nextChainKey;
state.receivingMessageNumber++;
// 4. Prepare AAD (must match encryption)
const aad = new Uint8Array(envelope.dhPublicKey.length + 8);
aad.set(envelope.dhPublicKey);
new DataView(aad.buffer, envelope.dhPublicKey.length).setUint32(0, envelope.messageNumber);
// 5. Decrypt with AES-GCM
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: envelope.iv, additionalData: aad },
await importAESKey(messageKey),
envelope.ciphertext
);
// 6. Securely delete message key
messageKey.fill(0);
return new TextDecoder().decode(plaintext);
}
Why Double Ratchet Works Unchanged in P2P
The Double Ratchet algorithm is transport-agnostic:
✅ Doesn't care how messages are delivered (WebRTC, TCP, carrier pigeon) ✅ Doesn't require a server to store state ✅ Handles out-of-order delivery natively ✅ Each peer maintains their own state independently ✅ All operations use standard cryptographic primitives (ECDH, HKDF, AES-GCM, HMAC)
The only requirement is that both parties:
- Start with the same shared secret (from X3DH)
- Can exchange message envelopes in some way
- Maintain their ratchet state
Code Examples and Walkthrough
Let's walk through a complete P2P encrypted messaging session between Alice and Bob using the cryptography module functions.
Complete P2P Messaging Lifecycle
Here's the full flow from WebRTC connection to encrypted message exchange:
Step-by-Step Implementation
Step 1: Initialize Users
Both Alice and Bob initialize their cryptographic identities:
// Reference: initializeSignalUser() from cryptography module
// See: /cryptography/src/stories/components/Cryptography.tsx:770
import { useCryptography } from '@/cryptography';
const crypto = useCryptography();
// Alice initializes
const alice = await crypto.initializeSignalUser("Alice");
// Creates:
// - Identity key pair (X25519)
// - Identity signing key pair (Ed25519)
// - Signed prekey pair
// - Signature over prekey
// - One-time prekeys (optional, not used in P2P)
// Bob initializes
const bob = await crypto.initializeSignalUser("Bob");
Step 2: Exchange Public Key Bundles
Over the WebRTC data channel:
// Reference: getSignalPublicKeyBundle() from cryptography module
// See: /cryptography/src/stories/components/Cryptography.tsx:872
// Alice gets her bundle to send
const aliceBundle = await crypto.getSignalPublicKeyBundle(alice);
// Bundle contains:
// {
// identityKey: ArrayBuffer (X25519 public key),
// identitySigningKey: ArrayBuffer (Ed25519 public key),
// signedPrekey: ArrayBuffer,
// signedPrekeySignature: ArrayBuffer,
// oneTimePrekey: ArrayBuffer | null
// }
// Bob gets his bundle to send
const bobBundle = await crypto.getSignalPublicKeyBundle(bob);
// Exchange over WebRTC
webrtcDataChannel.send(JSON.stringify({
type: 'key-bundle',
bundle: {
identityKey: arrayBufferToBase64(aliceBundle.identityKey),
identitySigningKey: arrayBufferToBase64(aliceBundle.identitySigningKey),
signedPrekey: arrayBufferToBase64(aliceBundle.signedPrekey),
signedPrekeySignature: arrayBufferToBase64(aliceBundle.signedPrekeySignature)
}
}));
Step 3: Perform X3DH Key Exchange
// Reference: performSignalX3DHKeyExchange(), deriveSignalSharedSecret()
// See: /cryptography/src/stories/components/Cryptography.tsx:916, :1065
// Alice initiates the key exchange
const aliceExchangeResult = await crypto.performSignalX3DHKeyExchange(
alice,
bobBundle
);
// Returns:
// {
// masterSecret: ArrayBuffer (32 bytes),
// aliceEphemeralPublic: ArrayBuffer,
// usedOneTimePrekey: boolean
// }
// Alice sends her ephemeral key to Bob
webrtcDataChannel.send(aliceExchangeResult.aliceEphemeralPublic);
// Bob derives the same secret
const aliceIdentityPublic = await crypto.exportSignalPublicKey(
alice.identityKeyPair.publicKey
);
const bobSecret = await crypto.deriveSignalSharedSecret(
bob,
aliceExchangeResult.aliceEphemeralPublic,
aliceIdentityPublic,
false, // no one-time prekey used
null
);
// Verify both sides have the same secret
const aliceSecretHex = crypto.bufferToSignalHex(aliceExchangeResult.masterSecret);
const bobSecretHex = crypto.bufferToSignalHex(bobSecret);
console.assert(aliceSecretHex === bobSecretHex, "Secrets must match!");
Step 4: Initialize Double Ratchet
// Reference: initializeDoubleRatchet()
// See: /cryptography/src/stories/components/Cryptography.tsx:1307
// Alice initializes as initiator
const aliceRatchetState = await crypto.initializeDoubleRatchet(
aliceExchangeResult.masterSecret,
true // isInitiator
);
// Bob initializes as responder
const bobRatchetState = await crypto.initializeDoubleRatchet(
bobSecret,
false // isInitiator=false
);
// State now contains:
// - rootKey
// - sendingChainKey / receivingChainKey
// - sendingDHKeyPair
// - message counters
// - skippedMessageKeys Map
Step 5: Send Encrypted Messages
// Reference: doubleRatchetEncrypt(), doubleRatchetDecrypt()
// See: /cryptography/src/stories/components/Cryptography.tsx:1548, :1626
// Alice sends a message
const message1 = "Hello Bob! This is encrypted with Double Ratchet.";
const envelope1 = await crypto.doubleRatchetEncrypt(aliceRatchetState, message1);
// Envelope structure:
// {
// dhPublicKey: Uint8Array,
// messageNumber: 0,
// previousChainLength: 0,
// ciphertext: Uint8Array,
// iv: Uint8Array,
// timestamp: number
// }
// Send over WebRTC
webrtcDataChannel.send(JSON.stringify({
type: 'encrypted-message',
envelope: serializeEnvelope(envelope1)
}));
// Bob receives and decrypts
const decrypted1 = await crypto.doubleRatchetDecrypt(bobRatchetState, envelope1);
console.log(decrypted1); // "Hello Bob! This is encrypted with Double Ratchet."
// Bob replies
const message2 = "Hi Alice! The Double Ratchet is working perfectly!";
const envelope2 = await crypto.doubleRatchetEncrypt(bobRatchetState, message2);
webrtcDataChannel.send(JSON.stringify({
type: 'encrypted-message',
envelope: serializeEnvelope(envelope2)
}));
// Alice decrypts Bob's reply
const decrypted2 = await crypto.doubleRatchetDecrypt(aliceRatchetState, envelope2);
console.log(decrypted2); // "Hi Alice! The Double Ratchet is working perfectly!"
Full Working Example
Here's a simplified but complete implementation:
// P2P Encrypted Messaging Session
class P2PSignalSession {
constructor(peerName, webrtcChannel) {
this.peerName = peerName;
this.channel = webrtcChannel;
this.crypto = useCryptography();
this.user = null;
this.ratchetState = null;
}
async initialize() {
// Initialize cryptographic identity
this.user = await this.crypto.initializeSignalUser(this.peerName);
// Get and send our public key bundle
const myBundle = await this.crypto.getSignalPublicKeyBundle(this.user);
this.channel.send(JSON.stringify({
type: 'key-bundle',
bundle: myBundle
}));
// Wait for peer's bundle
const peerBundle = await this.waitForPeerBundle();
// Perform key exchange
const exchangeResult = await this.crypto.performSignalX3DHKeyExchange(
this.user,
peerBundle
);
// Initialize Double Ratchet
this.ratchetState = await this.crypto.initializeDoubleRatchet(
exchangeResult.masterSecret,
this.peerName === "Alice" // Alice is initiator
);
console.log(`${this.peerName}: Session initialized and ready for messaging!`);
}
async sendMessage(plaintext) {
const envelope = await this.crypto.doubleRatchetEncrypt(
this.ratchetState,
plaintext
);
this.channel.send(JSON.stringify({
type: 'encrypted-message',
envelope
}));
console.log(`${this.peerName} sent: "${plaintext}"`);
}
async receiveMessage(envelope) {
const plaintext = await this.crypto.doubleRatchetDecrypt(
this.ratchetState,
envelope
);
console.log(`${this.peerName} received: "${plaintext}"`);
return plaintext;
}
async waitForPeerBundle() {
return new Promise((resolve) => {
this.channel.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'key-bundle') {
resolve(data.bundle);
}
};
});
}
}
// Usage
const aliceSession = new P2PSignalSession("Alice", webrtcChannelA);
const bobSession = new P2PSignalSession("Bob", webrtcChannelB);
await Promise.all([
aliceSession.initialize(),
bobSession.initialize()
]);
// Now messaging is ready!
await aliceSession.sendMessage("Hello Bob!");
await bobSession.sendMessage("Hi Alice!");
Browser Compatibility
This implementation uses the WebCrypto API available in all modern browsers:
// Key algorithms supported:
// ✅ X25519 - ECDH key agreement (Chrome 111+, Firefox 111+, Safari 17+)
// ✅ Ed25519 - Digital signatures (Chrome 113+, Firefox 113+, Safari 17+)
// ✅ AES-GCM - Authenticated encryption (all browsers)
// ✅ HKDF - Key derivation (all browsers)
// ✅ HMAC-SHA256 - Message authentication (all browsers)
// Feature detection:
async function checkCryptoSupport() {
try {
await crypto.subtle.generateKey(
{ name: "X25519" },
true,
["deriveBits"]
);
return true;
} catch (error) {
console.error("X25519 not supported in this browser");
return false;
}
}
Testing and Validation
Testing cryptographic implementations is critical. Here's how to validate that your P2P Signal Protocol implementation works correctly.
Test 1: Key Agreement Verification
The most fundamental test is verifying that both parties derive the same shared secret:
// Reference: demonstrateSignalProtocol() in cryptography module
// See: /cryptography/src/tests/signal-protocol.test.js
async function testKeyAgreement() {
// Initialize both users
const alice = await crypto.initializeSignalUser("Alice");
const bob = await crypto.initializeSignalUser("Bob");
// Get Bob's public key bundle
const bobBundle = await crypto.getSignalPublicKeyBundle(bob);
// Alice performs X3DH
const aliceResult = await crypto.performSignalX3DHKeyExchange(alice, bobBundle);
// Bob derives the same secret
const aliceIdentityPublic = await crypto.exportSignalPublicKey(
alice.identityKeyPair.publicKey
);
const bobSecret = await crypto.deriveSignalSharedSecret(
bob,
aliceResult.aliceEphemeralPublic,
aliceIdentityPublic,
aliceResult.usedOneTimePrekey,
bobBundle.oneTimePrekey
);
// Verify secrets match
const aliceSecretHex = crypto.bufferToSignalHex(aliceResult.masterSecret);
const bobSecretHex = crypto.bufferToSignalHex(bobSecret);
console.assert(
aliceSecretHex === bobSecretHex,
`Secrets must match!\nAlice: ${aliceSecretHex}\nBob: ${bobSecretHex}`
);
console.log("✅ Test 1 passed: Both parties derived the same shared secret");
}
Test 2: Message Encryption/Decryption
Verify that encrypted messages can be decrypted correctly:
// Reference: demonstrateDoubleRatchet() in cryptography module
// See: /cryptography/src/tests/double-ratchet.test.js
async function testMessageEncryption() {
// Setup (reuse from previous test)
const { aliceState, bobState } = await setupRatchetStates();
// Alice encrypts a message
const plaintext = "Secret message for testing";
const envelope = await crypto.doubleRatchetEncrypt(aliceState, plaintext);
// Bob decrypts it
const decrypted = await crypto.doubleRatchetDecrypt(bobState, envelope);
console.assert(
decrypted === plaintext,
`Message mismatch!\nOriginal: "${plaintext}"\nDecrypted: "${decrypted}"`
);
console.log("✅ Test 2 passed: Message encrypted and decrypted correctly");
}
Test 3: Forward Secrecy
Verify that old messages cannot be decrypted after key deletion:
async function testForwardSecrecy() {
const { aliceState, bobState } = await setupRatchetStates();
// Exchange several messages
const messages = ["Message 1", "Message 2", "Message 3"];
const envelopes = [];
for (const msg of messages) {
const env = await crypto.doubleRatchetEncrypt(aliceState, msg);
envelopes.push(env);
await crypto.doubleRatchetDecrypt(bobState, env);
}
// Now try to decrypt the first message again with a fresh state
// This should FAIL because we've ratcheted the keys forward
const freshBobState = await setupFreshState(bobSecret);
try {
await crypto.doubleRatchetDecrypt(freshBobState, envelopes[0]);
console.error("❌ Test 3 failed: Old message was decryptable (no forward secrecy!)");
} catch (error) {
console.log("✅ Test 3 passed: Old messages cannot be decrypted (forward secrecy works)");
}
}
Test 4: Out-of-Order Message Handling
Verify that messages can arrive out of order and still be decrypted:
async function testOutOfOrderMessages() {
const { aliceState, bobState } = await setupRatchetStates();
// Alice sends 3 messages
const msg1 = await crypto.doubleRatchetEncrypt(aliceState, "First");
const msg2 = await crypto.doubleRatchetEncrypt(aliceState, "Second");
const msg3 = await crypto.doubleRatchetEncrypt(aliceState, "Third");
// Bob receives them out of order: 3, 1, 2
const decrypted3 = await crypto.doubleRatchetDecrypt(bobState, msg3);
console.assert(decrypted3 === "Third", "Message 3 failed");
const decrypted1 = await crypto.doubleRatchetDecrypt(bobState, msg1);
console.assert(decrypted1 === "First", "Message 1 failed");
const decrypted2 = await crypto.doubleRatchetDecrypt(bobState, msg2);
console.assert(decrypted2 === "Second", "Message 2 failed");
console.log("✅ Test 4 passed: Out-of-order messages decrypted correctly");
}
Test 5: DH Ratchet Step
Verify that the DH ratchet updates keys correctly:
async function testDHRatchetStep() {
const { aliceState, bobState } = await setupRatchetStates();
// Alice sends a message (includes her DH public key)
const msg1 = await crypto.doubleRatchetEncrypt(aliceState, "Message 1");
await crypto.doubleRatchetDecrypt(bobState, msg1);
// Store Bob's current root key
const bobRootKeyBefore = new Uint8Array(bobState.rootKey);
// Bob replies (triggers DH ratchet on Alice's side)
const msg2 = await crypto.doubleRatchetEncrypt(bobState, "Reply");
await crypto.doubleRatchetDecrypt(aliceState, msg2);
// Alice's root key should have changed (DH ratchet occurred)
const aliceRootKeyAfter = new Uint8Array(aliceState.rootKey);
// They should NOT be equal (DH ratchet updated the root key)
const rootKeysMatch = aliceRootKeyAfter.every(
(byte, i) => byte === bobRootKeyBefore[i]
);
console.assert(
!rootKeysMatch,
"Root keys should differ after DH ratchet"
);
console.log("✅ Test 5 passed: DH ratchet step updated root keys");
}
Test 6: Signature Verification
Verify that invalid signatures are rejected (MITM protection):
async function testSignatureVerification() {
const alice = await crypto.initializeSignalUser("Alice");
const bob = await crypto.initializeSignalUser("Bob");
const eve = await crypto.initializeSignalUser("Eve"); // Attacker
// Get Bob's bundle
const bobBundle = await crypto.getSignalPublicKeyBundle(bob);
// Eve tries to impersonate Bob by replacing his signed prekey
const eveBundle = await crypto.getSignalPublicKeyBundle(eve);
const fakeBundle = {
...bobBundle,
signedPrekey: eveBundle.signedPrekey // Eve's key
// But Bob's signature (won't match!)
};
try {
await crypto.performSignalX3DHKeyExchange(alice, fakeBundle);
console.error("❌ Test 6 failed: Accepted invalid signature (vulnerable to MITM!)");
} catch (error) {
if (error.message.includes("Invalid signature")) {
console.log("✅ Test 6 passed: Invalid signatures rejected (MITM protection works)");
} else {
console.error("❌ Test 6 failed with unexpected error:", error);
}
}
}
Test 7: Bi-directional Messaging
Verify that both parties can send and receive messages:
async function testBidirectionalMessaging() {
const { aliceState, bobState } = await setupRatchetStates();
// Multiple round-trip exchanges
const conversation = [
{ sender: "Alice", message: "Hello Bob!" },
{ sender: "Bob", message: "Hi Alice!" },
{ sender: "Alice", message: "How are you?" },
{ sender: "Bob", message: "I'm good, thanks!" },
];
for (const { sender, message } of conversation) {
if (sender === "Alice") {
const env = await crypto.doubleRatchetEncrypt(aliceState, message);
const decrypted = await crypto.doubleRatchetDecrypt(bobState, env);
console.assert(decrypted === message, `Alice's message failed: ${message}`);
} else {
const env = await crypto.doubleRatchetEncrypt(bobState, message);
const decrypted = await crypto.doubleRatchetDecrypt(aliceState, env);
console.assert(decrypted === message, `Bob's message failed: ${message}`);
}
}
console.log("✅ Test 7 passed: Bidirectional messaging works correctly");
}
Running the Full Test Suite
async function runAllTests() {
console.log("🧪 Running P2P Signal Protocol Test Suite\n");
try {
await testKeyAgreement();
await testMessageEncryption();
await testForwardSecrecy();
await testOutOfOrderMessages();
await testDHRatchetStep();
await testSignatureVerification();
await testBidirectionalMessaging();
console.log("\n✅ All tests passed! P2P Signal Protocol implementation is correct.");
} catch (error) {
console.error("\n❌ Test suite failed:", error);
}
}
// Run the tests
runAllTests();
Browser Testing Checklist
Test across different browsers to ensure compatibility:
- ✅ Chrome/Edge (Chromium-based) - Latest version
- ✅ Firefox - Latest version
- ✅ Safari - Version 17+ (for X25519/Ed25519 support)
- ⚠️ Safari < 17 - May need fallback to WebAssembly implementation
Security Validation
Beyond functional testing, validate security properties:
- Key Secrecy: Keys are never exposed in plain text
- Forward Secrecy: Old keys are securely erased after use
- Replay Protection: Message numbers prevent replay attacks
- Authentication: Signature verification prevents impersonation
- Integrity: AES-GCM provides authentication (detect tampering)
Conclusion and Future Work
We've successfully adapted the Signal Protocol for true peer-to-peer messaging by rethinking the X3DH key agreement while keeping the Double Ratchet algorithm unchanged. This demonstrates that strong end-to-end encryption doesn't require centralized infrastructure.
Summary of Adaptations
What Changed:
- ❌ Removed pre-key server → Real-time key exchange over WebRTC
- ❌ Removed one-time prekeys → Simplified to 3-DH (from 4-DH)
- ❌ Removed asynchronous messaging → Both peers must be online
What Stayed the Same:
- ✅ X25519/Ed25519 cryptography → Same algorithms
- ✅ HKDF key derivation → Same process
- ✅ Double Ratchet algorithm → 100% unchanged
- ✅ Forward secrecy → Maintained
- ✅ Break-in recovery → Maintained
- ✅ Authentication → Signature verification
Security Comparison
Security Property | Traditional Signal | P2P Signal | Status |
---|---|---|---|
End-to-end encryption | ✅ | ✅ | Equal |
Forward secrecy | ✅ | ✅ | Equal |
Break-in recovery | ✅ | ✅ | Equal |
Mutual authentication | ✅ | ✅ | Equal |
Deniability | ✅ | ✅ | Equal |
MITM protection | ✅ | ✅ | Equal |
Defense in depth | ⚠️ (single layer) | ✅ (WebRTC + Signal) | Improved |
Asynchronous messaging | ✅ | ❌ | Trade-off |
One-time prekey FS | ✅ (4-DH) | ⚠️ (3-DH + WebRTC) | Comparable |
Metadata privacy | ⚠️ (server sees) | ✅ (no server) | Improved |
Decentralization | ❌ | ✅ | Improved |
Real-World Applications
This P2P adaptation is ideal for:
✅ Perfect Use Cases:
- Video/voice call apps with text chat overlay
- Screen sharing with encrypted annotations
- Collaborative editing tools
- Gaming chat systems
- Real-time collaborative whiteboards
⚠️ Limited Use Cases:
- Traditional asynchronous messaging (requires both online)
- Group chats (needs additional protocol extensions)
- Multi-device synchronization (needs state sharing protocol)
Future Enhancements
Several improvements could extend this implementation:
1. Session Resumption
Store encrypted ratchet state in IndexedDB to resume sessions after disconnect:
// Save state to IndexedDB
const serializedState = await crypto.serializeDoubleRatchetState(aliceState);
await indexedDB.put('ratchet-state', {
peerId: 'bob',
state: serializedState,
timestamp: Date.now()
});
// Resume later
const stored = await indexedDB.get('ratchet-state', 'bob');
const resumedState = await deserializeDoubleRatchetState(stored.state);
2. Multi-Device Support
Extend to support multiple devices per user:
- Maintain separate ratchet states per device
- Broadcast messages to all peer devices
- Synchronize device lists over encrypted channel
3. Group Messaging
Implement sender keys for efficient group messaging:
- Each participant maintains a sender key chain
- Sender encrypts once for the group
- Recipients share sender key chains
- Significantly more efficient than encrypting per-recipient
4. Offline Message Queue
Use distributed storage (IPFS, GunDB) as a temporary message queue:
- Encrypt messages with recipient's long-term public key
- Store in DHT/distributed database
- Recipient polls for messages when online
- Provides limited asynchronous messaging
5. Perfect Forward Secrecy Improvements
Add optional one-time prekeys stored in a distributed hash table:
- Generate and publish OTPKs to DHT
- Include DHT lookup in P2P handshake
- Restore full 4-DH security if OTPKs available
- Falls back to 3-DH if DHT unavailable
Implementation Resources
Try the Implementation:
- Cryptography Module - Full implementation with Storybook demos
- Live Demo - Try P2P encrypted chat
- Documentation - Architecture details
Learn More:
- Signal Protocol Specification - Official Signal documentation
- X3DH Specification - Key agreement details
- Double Ratchet Specification - Ratcheting algorithm
- WebCrypto API - Browser crypto docs
Final Thoughts
The Signal Protocol's elegance lies in its modularity—the key agreement (X3DH) and the ongoing encryption (Double Ratchet) are separate concerns. This made adapting it for P2P straightforward: we modified the key exchange to work in real-time over WebRTC while leaving the Double Ratchet completely unchanged.
The result is a protocol that provides Signal-level security in a fully decentralized environment. While it sacrifices asynchronous messaging, it gains true peer-to-peer operation with no servers to trust, compromise, or shut down.
An important advantage: The P2P implementation benefits from defense in depth—WebRTC's DTLS transport layer already provides encryption with forward secrecy, and we layer the Signal Protocol on top of that. This means even if the 3-DH key exchange has a slightly smaller security margin than 4-DH, the underlying WebRTC encryption provides an additional protective layer that traditional Signal doesn't have at the transport level.
For applications where both parties are online during communication—video calls, gaming, collaborative tools—this P2P variant offers the best of both worlds: military-grade encryption with layered security and complete decentralization.
The future of secure communication doesn't have to rely on centralized infrastructure. As this implementation shows, we can have our cake and eat it too: Signal-level security in a truly peer-to-peer world, with defense-in-depth encryption at both transport and application layers.
Try It Yourself
Explore the cryptography module and experiment with the P2P Signal Protocol implementation. The codebase includes comprehensive Storybook demos and test suites to help you understand and extend the protocol.
Have questions or improvements? Open an issue or contribute to the project!
This article is part of our ongoing research into decentralized, privacy-preserving communication systems. For more technical deep-dives, check out our other posts on decentralized architecture and security in P2P systems.