Building Scalable Group Messaging with MLS (Message Layer Security)
⚠️ WARNING: This document is not finished. The details in this document are subject to change.
End-to-end encrypted messaging for two people is a solved problem—Signal Protocol has set the gold standard. But what happens when you want to scale that security to group chats with dozens or hundreds of participants? Traditional pairwise encryption becomes a nightmare: N participants require N(N-1)/2 encrypted channels, each with its own key management overhead.
Enter MLS (Message Layer Security), the IETF's RFC 9420 standard designed specifically for scalable group messaging. MLS provides the same strong security guarantees as Signal Protocol—forward secrecy, post-compromise security, authentication—but does so efficiently for groups of any size.
In this article, we'll explore how MLS works, why it's a game-changer for group messaging, and walk through a complete browser-based implementation using the ts-mls library. We'll cover everything from the TreeKEM algorithm to practical P2P integration with WebRTC.
Introduction to MLS
Message Layer Security (MLS) is a cryptographic protocol designed to provide end-to-end encryption for group messaging at scale. Published as RFC 9420 by the IETF in July 2023, MLS represents years of cryptographic research and real-world testing.
What Makes MLS Special?
Unlike traditional approaches to group messaging, MLS is built from the ground up for efficiency and security:
🔐 End-to-End Encryption
- Messages encrypted on sender's device, decrypted only on recipients' devices
- No server can read message contents
- Same security level as Signal Protocol, but for groups
⚡ Scalable Key Management
- Logarithmic complexity for key updates: O(log N) instead of O(N)
- 100-person group? Only ~7 operations instead of 100
- 1000-person group? Only ~10 operations instead of 1000
🔄 Forward Secrecy
- Compromise of today's keys doesn't reveal yesterday's messages
- Automatic key rotation with each message
- Protection even if long-term identity keys are leaked
🛡️ Post-Compromise Security
- System "heals" from key compromise
- New Diffie-Hellman exchanges generate fresh key material
- Attacker loses access after compromise ends
✅ Asynchronous Operations
- Members can join groups while offline
- No requirement for all participants to be online simultaneously
- Server-based key package distribution
MLS vs Signal Protocol
| Feature | Signal Protocol | MLS Protocol |
|---|---|---|
| Use Case | 1:1 messaging | Group messaging |
| Participants | 2 | 2 to thousands |
| Key Update Complexity | O(1) | O(log N) |
| Algorithm | Double Ratchet | TreeKEM |
| Key Structure | Chain keys | Binary tree |
| Asynchronous | ✅ Yes | ✅ Yes |
| Forward Secrecy | ✅ Yes | ✅ Yes |
| Post-Compromise Security | ✅ Yes | ✅ Yes |
| Standardization | De facto | RFC 9420 (IETF) |
Real-World Applications
MLS is already being adopted by major platforms:
✅ Messaging Apps
- Large group chats (family, friends, communities)
- Team collaboration platforms
- Enterprise secure messaging
✅ Video Conferencing
- Encrypted group video calls with text chat
- Webinars with encrypted Q&A
- Virtual classrooms
✅ IoT & Industrial
- Device-to-device group communication
- Sensor networks
- Industrial control systems
✅ Blockchain & Web3
- DAO governance discussions
- Private group coordination
- NFT community chats
How MLS Fits the Modern Web
The beauty of MLS for web developers is that it's designed to work in browsers:
- WebCrypto API Support: X25519, Ed25519, AES-GCM, HKDF
- Pure JavaScript: No native dependencies required (with libraries like ts-mls)
- WebRTC Integration: Works seamlessly with P2P data channels
- IndexedDB Storage: Persist group state locally
- Service Workers: Background key rotation and updates
In the rest of this article, we'll build a complete MLS implementation that runs entirely in the browser, providing Signal-level security for group chats without any centralized infrastructure.
The Group Messaging Problem
Before diving into MLS, let's understand why group messaging is fundamentally different from one-to-one encryption and why naive approaches don't scale.
The Naive Approach: Pairwise Encryption
The simplest way to do group messaging is to use pairwise encryption—encrypt each message separately for each recipient:
Problems with Pairwise Encryption:
❌ Bandwidth Explosion: Send N-1 copies of every message
- 10 people = 9 encrypted copies per message
- 100 people = 99 encrypted copies per message
- Video call with 50 people = 49 copies of every video frame!
❌ Computational Overhead: Encrypt each message N-1 times
- Each encryption requires ECDH, HKDF, AES-GCM operations
- Mobile devices quickly drain battery
- Desktop clients consume unnecessary CPU
❌ State Management Nightmare: Maintain N-1 pairwise sessions
- Each pair needs separate Double Ratchet state
- Adding/removing members requires N updates
- Synchronization becomes extremely complex
❌ No Group Context: No shared group state
- Can't verify all members see same group membership
- No transcript consistency
- Difficult to implement group-level features
The Scalability Problem
Let's quantify the problem with a real-world example:
Scenario: 50-Person Video Call with Text Chat
Pairwise Encryption Approach:
Messages sent per person per message: 49
Encryption operations: 49
Key pairs to manage: 49
Total messages in system: 50 × 49 = 2,450 per message!
For 100 messages in the chat:
Total encrypted messages: 245,000
Total encryption operations: 245,000
MLS Approach:
Messages sent per person per message: 1
Encryption operations: 1
Key updates per person: ~6 (log₂50)
Total messages in system: 50 per message
For 100 messages in the chat:
Total encrypted messages: 5,000
Total encryption operations: 5,000
Result: MLS is 49x more efficient in bandwidth and computation!
Requirements for a Group Messaging Protocol
A secure, scalable group messaging protocol must provide:
Security Requirements:
- ✅ End-to-End Encryption: Messages only readable by group members
- ✅ Forward Secrecy: Past messages secure even if keys compromised
- ✅ Post-Compromise Security: Recovery from key compromise
- ✅ Authentication: Verify sender identity
- ✅ Transcript Consistency: All members agree on message order and group state
Efficiency Requirements: 6. ✅ Logarithmic Key Updates: O(log N) complexity 7. ✅ Single Message Encryption: One ciphertext for all recipients 8. ✅ Efficient Member Changes: Add/remove without rekeying everyone 9. ✅ Asynchronous Operations: Work when members offline
Operational Requirements: 10. ✅ Dynamic Membership: Add/remove members at any time 11. ✅ Crash Recovery: Rejoin after network failure 12. ✅ State Consistency: All members synchronized
Enter TreeKEM
MLS solves these problems with TreeKEM, a key agreement protocol based on binary trees. Instead of maintaining pairwise keys, TreeKEM organizes group members into a binary tree where:
- Leaf nodes = Group members
- Parent nodes = Shared secrets between subtrees
- Root node = Group secret shared by all
Key Insight: When Alice sends a message:
- She encrypts once with the root secret
- All 4 members can decrypt using their path to the root
- Only ~log₂(4) = 2 operations needed per member
This is the fundamental innovation that makes MLS scale to thousands of participants.
How MLS Works: Protocol Deep Dive
Now let's explore the MLS protocol in detail—how keys are managed, how groups evolve, and how messages stay secure.
Core Components
MLS consists of several key components working together:
1. Key Packages
A key package is like a business card for joining MLS groups. It contains:
KeyPackage = {
version: 'mls10', // MLS protocol version
cipherSuite: 0x0001, // Cryptographic algorithms
initKey: <X25519 public key>, // For encrypting Welcome
leafNode: {
credential: {
type: 'basic',
identity: 'alice@example.com' // User identity
},
capabilities: [...], // Supported features
encryptionKey: <X25519 public>, // For TreeKEM
signatureKey: <Ed25519 public>, // For authentication
lifetime: 90 days // Validity period
},
signature: <Ed25519 signature> // Sign the whole package
}
Purpose:
- Distributed to others before they can add you to a group
- Uploaded to a server's "key package store"
- Consumed once and discarded (like one-time prekeys)
2. Ratchet Tree
The ratchet tree is a binary tree structure storing the key material:
Key Properties:
- Leaf nodes (odd indices): Contain member credentials and private keys
- Parent nodes (even indices): Contain shared secrets via ECDH
- Root node: Derives the encryption secret for all messages
- Height: log₂(N) where N = number of members
Navigation Rules:
- Node at index
i:- Left child:
2i + 1 - Right child:
2i + 2 - Parent:
(i - 1) / 2
- Left child:
3. Group Context
The group context provides shared group state:
GroupContext = {
version: 'mls10',
cipherSuite: 0x0001,
groupId: 'secure-chat-room',
epoch: 42, // Increments with each change
treeHash: <hash of ratchet tree>, // Ensures consistency
confirmedTranscript: <hash>, // Message history commitment
extensions: [...] // Group metadata
}
Purpose:
- Synchronized across all members
- Changes only via commits
- Ensures all members have identical view of group
4. Epochs
An epoch is a period of time during which the group state is stable:
Epoch Transitions:
- Triggered by commits (member add/remove, key rotation)
- Increments epoch number
- Updates tree hash
- All members must process the commit to stay synchronized
Message Flow: Group Creation
Let's walk through creating an MLS group step by step:
Key Steps Explained:
- Key Package Upload: Before joining, Bob uploads his key package to a server
- Tree Construction: Alice adds Bob's leaf node and computes shared parent nodes
- Welcome Message: Encrypted with Bob's init key, contains:
- Ratchet tree structure
- Group context
- Bob's position in the tree
- Commit Message: Public update sent to existing members (currently just Alice)
Message Flow: Sending Encrypted Messages
Once the group is established, sending messages is straightforward:
Encryption Process:
// Reference: encryptMessage() in MLSManager.tsx:352
async encryptMessage(groupId, plaintext) {
// 1. Get current group state
const groupState = this.groups.get(groupId);
// 2. Derive message key from current epoch
const messageKey = deriveMessageKey(groupState.encryptionSecret);
// 3. Encrypt with AES-GCM
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: randomIV() },
messageKey,
plaintextBytes
);
// 4. Create envelope
return {
groupId,
epoch: groupState.groupContext.epoch,
ciphertext,
timestamp: Date.now()
};
}
Key Rotation: Maintaining Forward Secrecy
Forward secrecy requires regularly updating keys. In MLS, this is done via path updates:
Path Update Details:
-
Alice's Path: Suppose Alice is at leaf index 3 in the tree
- Path to root: [3, 1, 0]
- Alice updates nodes 3, 1, and 0
-
Encryption for Copath:
- Node 3's sibling (node 4 = Bob): Encrypt for Bob
- Node 1's sibling (node 2 = Charlie+David): Encrypt for right subtree
- This ensures everyone can derive the new root
-
Efficiency:
- Only log₂(N) nodes updated
- Only log₂(N) encryptions needed
- Far better than O(N) pairwise updates
TreeKEM Algorithm
The TreeKEM algorithm is the heart of MLS key management. Here's how it works:
Tree Structure:
Level 0: [0] Root
/ \
Level 1: [1] [2]
/ \ / \
Level 2: [3] [4] [5] [6]
Alice Bob Charlie David
Node Secrets:
- Each node has a secret and a public key
- Secrets are known only to members in that subtree
- Public keys are known to everyone
Key Derivation:
// Parent secret = HKDF(leftSecret || rightSecret)
parentSecret = HKDF(
leftChildSecret,
rightChildSecret,
"MLS 1.0 tree node"
);
// Public key via ECDH with generator
parentPublicKey = X25519(parentSecret, G);
Path Update Algorithm:
// When Alice updates her path:
for (let node of pathToRoot) {
// 1. Generate new secret
node.secret = randomBytes(32);
node.publicKey = X25519(node.secret, G);
// 2. Encrypt for copath (sibling subtree)
const sibling = getSibling(node);
const ciphertext = encryptForSubtree(sibling, node.secret);
pathUpdate.push(ciphertext);
}
// 3. Recompute parent secrets bottom-up
for (let node of pathToRoot.reverse()) {
if (node.isParent) {
node.secret = HKDF(
leftChild.secret,
rightChild.secret,
"MLS 1.0 tree node"
);
}
}
Commit Messages: Group Updates
A commit is how the group state changes:
Commit = {
proposals: [
{ type: 'add', keyPackage: <new member> },
{ type: 'remove', removed: 2 },
{ type: 'update', leafNode: <new keys> }
],
path: {
leafNode: <new leaf node>,
nodes: [<encrypted parent secrets>]
},
confirmationTag: <HMAC of new tree hash>,
signature: <Ed25519 signature>
}
Processing a Commit:
- Verify signature with sender's public key
- Apply proposals in order
- Decrypt path update (if present)
- Recompute tree hash
- Verify confirmation tag matches
- Increment epoch
Commit Types:
- Add Commit → Sent as PublicMessage (existing members)
- Remove Commit → Sent as PublicMessage
- Update Commit → Sent as PrivateMessage (key rotation only)
Welcome Messages: Onboarding New Members
A welcome message lets new members bootstrap their group state:
Welcome = {
cipherSuite: 0x0001,
secrets: [
{
// Encrypted for each new member's init key
keyPackageHash: <hash>,
encryptedGroupSecrets: <ciphertext>
}
],
encryptedGroupInfo: <ciphertext> // Contains ratchet tree, etc.
}
Welcome Processing:
// Reference: processWelcome() in MLSManager.tsx:281
async processWelcome(welcome, ratchetTree) {
// 1. Find our key package in welcome
const mySecret = findAndDecrypt(welcome.secrets, myInitKey);
// 2. Decrypt group info
const groupInfo = decrypt(welcome.encryptedGroupInfo, mySecret);
// 3. Reconstruct ratchet tree
const tree = ratchetTree || extractFromWelcome(welcome);
// 4. Find our leaf node
const myLeafIndex = findMyLeaf(tree, myKeyPackage);
// 5. Derive secrets along path to root
const pathSecrets = derivePathSecrets(tree, myLeafIndex, mySecret);
// 6. Compute root secret
const rootSecret = pathSecrets[0]; // Root is always index 0
// 7. Derive encryption keys
const encryptionSecret = HKDF(rootSecret, "MLS 1.0 encryption");
// 8. Initialize group state
this.groups.set(groupId, {
groupContext: groupInfo.groupContext,
ratchetTree: tree,
encryptionSecret,
...
});
}
Implementation: Building MLS in the Browser
Now let's dive into a real implementation using the ts-mls library. We'll build a complete MLS system that runs in any modern browser.
Architecture Overview
Our implementation consists of three layers:
Ciphersuite Selection
MLS supports multiple ciphersuites. We use the most widely supported:
// MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519
Ciphersuite = {
id: 0x0001,
kem: 'X25519', // Key Encapsulation Mechanism
aead: 'AES-128-GCM', // Authenticated Encryption
hash: 'SHA-256', // Hash function
signature: 'Ed25519' // Digital signatures
}
Why this ciphersuite?
- ✅ X25519: Fastest elliptic curve for ECDH, native browser support
- ✅ Ed25519: Fast signatures, small keys (32 bytes)
- ✅ AES-128-GCM: Hardware-accelerated, authenticated encryption
- ✅ SHA-256: Ubiquitous, FIPS-approved
- ✅ Browser Support: Chrome 111+, Firefox 111+, Safari 17+
MLSManager Class
The MLSManager class wraps ts-mls with a convenient API:
// Reference: src/crypto/MLS/MLSManager.tsx
export class MLSManager {
private userId: string;
private cipherSuite: CiphersuiteImpl;
private groups: Map<string, ClientState>;
private keyPackage: MLSKeyPackageBundle;
constructor(userId: string) {
this.userId = userId;
this.credential = {
credentialType: 'basic',
identity: new TextEncoder().encode(userId)
};
}
async initialize(): Promise<void> {
// Initialize ciphersuite
const cs = getCiphersuiteFromName(
'MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519'
);
this.cipherSuite = await nobleCryptoProvider.getCiphersuiteImpl(cs);
// Generate initial key package
await this.generateKeyPackage();
this.initialized = true;
}
// ... methods follow
}
Key Methods Walkthrough
1. Initialize
// Reference: MLSManager.tsx:82
async initialize() {
// Get ciphersuite implementation
const cipherSuiteName = 'MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519';
const cs = getCiphersuiteFromName(cipherSuiteName);
this.cipherSuite = await nobleCryptoProvider.getCiphersuiteImpl(cs);
// Mark as initialized BEFORE generating key package
// (avoids circular dependency with ensureInitialized check)
this.initialized = true;
// Generate key package
await this.generateKeyPackage();
}
Key Insight: Set initialized = true before calling generateKeyPackage() to avoid circular dependency.
2. Generate Key Package
// Reference: MLSManager.tsx:115
async generateKeyPackage() {
const keyPackageResult = await generateKeyPackage(
this.credential, // User identity
defaultCapabilities(), // Supported features
defaultLifetime, // 90 days
[], // Extensions
this.cipherSuite
);
this.keyPackage = {
...keyPackageResult,
userId: this.userId
};
return this.keyPackage;
}
Key Package Contents:
- Init Key (X25519 public): For encrypting Welcome messages
- Leaf Node: Contains credential, capabilities, encryption key, signature key
- Signature (Ed25519): Signs the entire package for authentication
3. Create Group
// Reference: MLSManager.tsx:152
async createGroup(groupId) {
const groupIdBytes = new TextEncoder().encode(groupId);
// Create group with ts-mls
const groupState = await createGroup(
groupIdBytes,
this.keyPackage.publicPackage,
this.keyPackage.privatePackage,
[], // Initial extensions
this.cipherSuite
);
// Store group state
this.groups.set(groupId, groupState);
return {
groupId: groupIdBytes,
members: [this.userId],
epoch: groupState.groupContext.epoch // Should be 0n (BigInt)
};
}
Initial State:
- Epoch = 0
- Ratchet tree has 1 leaf (the creator)
- Group context initialized
4. Add Members
// Reference: MLSManager.tsx:192
async addMembers(groupId, keyPackages) {
const groupState = this.groups.get(groupId);
// Create add proposals for each key package
const addProposals = keyPackages.map(kp => ({
proposalType: 'add',
add: { keyPackage: kp.publicPackage }
}));
// Create commit with add proposals
const commitResult = await createCommit(
{ state: groupState, cipherSuite: this.cipherSuite },
{ extraProposals: addProposals }
);
// Update local state
this.groups.set(groupId, commitResult.newState);
// Return welcome for new members AND commit for existing members
return {
welcome: commitResult.welcome,
ratchetTree: stripTrailingNulls(commitResult.newState.ratchetTree),
commit: commitResult.commit
};
}
Critical RFC 9420 Requirement:
The commit MUST be sent to all existing group members so they can process it with
processCommit()to stay synchronized.
Distribution Flow:
- Alice adds Bob →
addMembers()returns{ welcome, commit } - Alice sends
welcometo Bob (new member) - Alice sends
committo existing members (Charlie, David, etc.) - All existing members call
processCommit(commit)to update their state
5. Process Welcome
// Reference: MLSManager.tsx:281
async processWelcome(welcome, ratchetTree?) {
// Join group using welcome message
const groupState = await joinGroup(
welcome,
this.keyPackage.publicPackage,
this.keyPackage.privatePackage,
emptyPskIndex,
this.cipherSuite,
ratchetTree // Optional, can be extracted from Welcome
);
const groupId = new TextDecoder().decode(groupState.groupContext.groupId);
this.groups.set(groupId, groupState);
// Extract member identities from ratchet tree
const members = this.extractMembersFromState(groupState);
return {
groupId: groupState.groupContext.groupId,
members,
epoch: groupState.groupContext.epoch
};
}
Ratchet Tree Handling:
- RFC 9420: Interior null nodes are valid (represent unmerged parent nodes)
- Trailing nulls are stripped by sender
- Tree passed as-is to
joinGroup()
6. Encrypt Message
// Reference: MLSManager.tsx:352
async encryptMessage(groupId, plaintext) {
const groupState = this.groups.get(groupId);
const plaintextBytes = new TextEncoder().encode(plaintext);
// Create application message
const result = await createApplicationMessage(
groupState,
plaintextBytes,
this.cipherSuite
);
// Update group state (for key ratcheting)
this.groups.set(groupId, result.newState);
// Encode as MLS message
const encoded = encodeMlsMessage({
privateMessage: result.privateMessage,
wireformat: 'mls_private_message',
version: 'mls10'
});
return {
groupId: new TextEncoder().encode(groupId),
ciphertext: encoded,
timestamp: Date.now()
};
}
Encryption Process:
- Derive message key from current epoch's encryption secret
- Encrypt plaintext with AES-128-GCM
- Update sender's key material (forward secrecy)
- Encode as PrivateMessage wireformat
7. Decrypt Message
// Reference: MLSManager.tsx:399
async decryptMessage(envelope) {
const groupId = new TextDecoder().decode(envelope.groupId);
const groupState = this.groups.get(groupId);
// Decode MLS message
const [decodedMessage] = decodeMlsMessage(envelope.ciphertext, 0);
if (decodedMessage.wireformat !== 'mls_private_message') {
throw new Error('Expected private message');
}
// Process the private message
const result = await processPrivateMessage(
groupState,
decodedMessage.privateMessage,
emptyPskIndex,
this.cipherSuite
);
// Update group state
this.groups.set(groupId, result.newState);
if (result.kind !== 'applicationMessage') {
throw new Error('Expected application message');
}
return new TextDecoder().decode(result.message);
}
Decryption Process:
- Decode MLS message wireformat
- Extract PrivateMessage
- Derive message key from current epoch
- Decrypt with AES-128-GCM
- Update receiver's key material
8. Key Rotation
// Reference: MLSManager.tsx:450
async updateKey(groupId) {
const groupState = this.groups.get(groupId);
// Create update commit (forces path update)
const commitResult = await createCommit(
{ state: groupState, cipherSuite: this.cipherSuite },
{ forcePathUpdate: true }
);
// Update local state
this.groups.set(groupId, commitResult.newState);
// Return commit for other members to process
return commitResult.commit;
}
Path Update:
- Generates new leaf key pair
- Updates all parent nodes on path to root
- Encrypts each updated node for its copath
- Increments epoch
9. Process Commit
// Reference: MLSManager.tsx:491
async processCommit(groupId, commit) {
const groupState = this.groups.get(groupId);
let result;
// RFC 9420: Route based on wireformat
if (commit.wireformat === 'mls_public_message') {
// Add/remove commits
result = await processPublicMessage(
groupState,
commit.publicMessage,
emptyPskIndex,
this.cipherSuite
);
} else if (commit.wireformat === 'mls_private_message') {
// Update/key rotation commits
result = await processPrivateMessage(
groupState,
commit.privateMessage,
emptyPskIndex,
this.cipherSuite
);
}
// Update local state
this.groups.set(groupId, result.newState);
}
Commit Types:
- PublicMessage: Member add/remove (visible to existing members)
- PrivateMessage: Key updates (encrypted, only for group members)
Helper Functions
Strip Trailing Nulls
// Reference: MLSManager.tsx:33
function stripTrailingNulls(tree) {
let lastNonNull = tree.length - 1;
while (lastNonNull >= 0 && tree[lastNonNull] === null) {
lastNonNull--;
}
return tree.slice(0, lastNonNull + 1);
}
RFC 9420 Requirement: Trailing null nodes must be stripped before transmission to reduce bandwidth.
Extract Members
// Reference: MLSManager.tsx:702
extractMembersFromState(state) {
const members = [];
// Iterate through ratchet tree
for (let i = 0; i < state.ratchetTree.length; i++) {
const node = state.ratchetTree[i];
// Check if it's a leaf node with credential
if (node && node.nodeType === 'leaf' && node.leaf.credential) {
const identity = new TextDecoder().decode(node.leaf.credential.identity);
members.push(identity);
}
}
return members;
}
Browser Compatibility
The implementation uses WebCrypto APIs available in modern browsers:
// Feature detection
async function checkMLSSupport() {
try {
// Test X25519 support
await crypto.subtle.generateKey(
{ name: 'X25519' },
true,
['deriveBits']
);
// Test Ed25519 support
await crypto.subtle.generateKey(
{ name: 'Ed25519' },
true,
['sign', 'verify']
);
return true;
} catch (error) {
console.error('MLS not supported:', error);
return false;
}
}
Supported Browsers:
- ✅ Chrome/Edge 111+ (X25519 + Ed25519 support)
- ✅ Firefox 111+ (X25519 + Ed25519 support)
- ✅ Safari 17+ (X25519 + Ed25519 support)
- ⚠️ Older browsers: Require polyfills or WebAssembly fallback
Step-by-Step Tutorials
Let's build three practical examples to demonstrate MLS in action.
Tutorial 1: Basic 3-Person Group Chat
This tutorial creates a secure group chat between Alice, Bob, and Charlie.
// Reference: demonstrateGroupMessaging() example
import { MLSManager } from 'cryptography/Cryptography';
async function basicGroupChat() {
console.log('🚀 Starting MLS Group Chat Demo\n');
// ============================================
// STEP 1: Initialize all participants
// ============================================
console.log('Step 1: Initializing participants...');
const alice = new MLSManager('alice@example.com');
await alice.initialize();
console.log('✅ Alice initialized');
const bob = new MLSManager('bob@example.com');
await bob.initialize();
console.log('✅ Bob initialized');
const charlie = new MLSManager('charlie@example.com');
await charlie.initialize();
console.log('✅ Charlie initialized\n');
// ============================================
// STEP 2: Alice creates the group
// ============================================
console.log('Step 2: Alice creates group...');
const groupId = 'friends-chat';
const groupInfo = await alice.createGroup(groupId);
console.log(`✅ Group created: ${groupId}`);
console.log(` Epoch: ${groupInfo.epoch}`);
console.log(` Members: ${groupInfo.members.join(', ')}\n`);
// ============================================
// STEP 3: Add Bob and Charlie to the group
// ============================================
console.log('Step 3: Adding Bob and Charlie...');
// Get their key packages
const bobKeyPackage = bob.getKeyPackage();
const charlieKeyPackage = charlie.getKeyPackage();
// Alice adds both members
const addResult = await alice.addMembers(groupId, [
bobKeyPackage,
charlieKeyPackage
]);
console.log('✅ Members added to group');
console.log(` New epoch: ${(await alice.getGroupKeyInfo(groupId)).epoch}\n`);
// ============================================
// STEP 4: Bob and Charlie process welcome
// ============================================
console.log('Step 4: Bob and Charlie join...');
await bob.processWelcome(addResult.welcome, addResult.ratchetTree);
console.log('✅ Bob joined the group');
await charlie.processWelcome(addResult.welcome, addResult.ratchetTree);
console.log('✅ Charlie joined the group\n');
// Verify all at same epoch
const aliceInfo = await alice.getGroupKeyInfo(groupId);
const bobInfo = await bob.getGroupKeyInfo(groupId);
const charlieInfo = await charlie.getGroupKeyInfo(groupId);
console.log('📊 Epoch verification:');
console.log(` Alice: ${aliceInfo.epoch}`);
console.log(` Bob: ${bobInfo.epoch}`);
console.log(` Charlie: ${charlieInfo.epoch}`);
if (aliceInfo.epoch === bobInfo.epoch && bobInfo.epoch === charlieInfo.epoch) {
console.log('✅ All members synchronized!\n');
}
// ============================================
// STEP 5: Exchange encrypted messages
// ============================================
console.log('Step 5: Exchanging encrypted messages...\n');
// Alice sends a message
console.log('Alice: "Hello everyone!"');
const msg1 = await alice.encryptMessage(groupId, 'Hello everyone!');
const bobDecrypt1 = await bob.decryptMessage(msg1);
const charlieDecrypt1 = await charlie.decryptMessage(msg1);
console.log(` Bob received: "${bobDecrypt1}"`);
console.log(` Charlie received: "${charlieDecrypt1}"`);
// Bob replies
console.log('\nBob: "Hey Alice! Hi Charlie!"');
const msg2 = await bob.encryptMessage(groupId, 'Hey Alice! Hi Charlie!');
const aliceDecrypt2 = await alice.decryptMessage(msg2);
const charlieDecrypt2 = await charlie.decryptMessage(msg2);
console.log(` Alice received: "${aliceDecrypt2}"`);
console.log(` Charlie received: "${charlieDecrypt2}"`);
// Charlie replies
console.log('\nCharlie: "Hello friends!"');
const msg3 = await charlie.encryptMessage(groupId, 'Hello friends!');
const aliceDecrypt3 = await alice.decryptMessage(msg3);
const bobDecrypt3 = await bob.decryptMessage(msg3);
console.log(` Alice received: "${aliceDecrypt3}"`);
console.log(` Bob received: "${bobDecrypt3}"`);
console.log('\n🎉 Group chat working perfectly!');
// ============================================
// STEP 6: Cleanup
// ============================================
await alice.destroy();
await bob.destroy();
await charlie.destroy();
}
// Run the demo
basicGroupChat();
Expected Output:
🚀 Starting MLS Group Chat Demo
Step 1: Initializing participants...
✅ Alice initialized
✅ Bob initialized
✅ Charlie initialized
Step 2: Alice creates group...
✅ Group created: friends-chat
Epoch: 0
Members: alice@example.com
Step 3: Adding Bob and Charlie...
✅ Members added to group
New epoch: 1
Step 4: Bob and Charlie join...
✅ Bob joined the group
✅ Charlie joined the group
📊 Epoch verification:
Alice: 1
Bob: 1
Charlie: 1
✅ All members synchronized!
Step 5: Exchanging encrypted messages...
Alice: "Hello everyone!"
Bob received: "Hello everyone!"
Charlie received: "Hello everyone!"
Bob: "Hey Alice! Hi Charlie!"
Alice received: "Hey Alice! Hi Charlie!"
Charlie received: "Hey Alice! Hi Charlie!"
Charlie: "Hello friends!"
Alice received: "Hello friends!"
Bob received: "Hello friends!"
🎉 Group chat working perfectly!
Tutorial 2: Key Rotation for Forward Secrecy
This tutorial demonstrates how key rotation maintains forward secrecy.
async function keyRotationDemo() {
console.log('🔄 MLS Key Rotation Demo\n');
// Setup group (reuse code from Tutorial 1)
const alice = new MLSManager('alice@example.com');
const bob = new MLSManager('bob@example.com');
await alice.initialize();
await bob.initialize();
const groupId = 'secure-chat';
await alice.createGroup(groupId);
const bobKeyPackage = bob.getKeyPackage();
const addResult = await alice.addMembers(groupId, [bobKeyPackage]);
await bob.processWelcome(addResult.welcome, addResult.ratchetTree);
console.log('✅ Group setup complete\n');
// ============================================
// STEP 1: Send messages before rotation
// ============================================
console.log('Step 1: Messages before key rotation...');
const beforeInfo = await alice.getGroupKeyInfo(groupId);
console.log(`Epoch: ${beforeInfo.epoch}`);
console.log(`Tree Hash: ${beforeInfo.treeHash}\n`);
const msg1 = await alice.encryptMessage(groupId, 'Message before rotation');
console.log('Alice: "Message before rotation"');
console.log(`Bob: "${await bob.decryptMessage(msg1)}"\n`);
// ============================================
// STEP 2: Alice performs key rotation
// ============================================
console.log('Step 2: Alice initiates key rotation...');
const commit = await alice.updateKey(groupId);
console.log('✅ Alice rotated keys');
// Bob must process the commit to stay synchronized
await bob.processCommit(groupId, commit);
console.log('✅ Bob processed commit\n');
// ============================================
// STEP 3: Verify epoch changed
// ============================================
console.log('Step 3: Verify epoch progression...');
const afterAlice = await alice.getGroupKeyInfo(groupId);
const afterBob = await bob.getGroupKeyInfo(groupId);
console.log('Alice:');
console.log(` Epoch: ${afterAlice.epoch}`);
console.log(` Tree Hash: ${afterAlice.treeHash}`);
console.log('Bob:');
console.log(` Epoch: ${afterBob.epoch}`);
console.log(` Tree Hash: ${afterBob.treeHash}`);
if (afterAlice.epoch === afterBob.epoch && afterAlice.treeHash === afterBob.treeHash) {
console.log('✅ Both members synchronized at new epoch!\n');
}
// ============================================
// STEP 4: Messages after rotation use new keys
// ============================================
console.log('Step 4: Messages after rotation...');
const msg2 = await alice.encryptMessage(groupId, 'Message after rotation');
console.log('Alice: "Message after rotation"');
console.log(`Bob: "${await bob.decryptMessage(msg2)}"\n`);
const msg3 = await bob.encryptMessage(groupId, 'Rotation successful!');
console.log('Bob: "Rotation successful!"');
console.log(`Alice: "${await alice.decryptMessage(msg3)}"\n`);
// ============================================
// STEP 5: Demonstrate forward secrecy
// ============================================
console.log('Step 5: Forward secrecy verification...');
console.log('⚠️ Old keys cannot decrypt new messages');
console.log('⚠️ Compromise of old keys does not affect current messages');
console.log('✅ Forward secrecy maintained!\n');
console.log('📊 Summary:');
console.log(` Epoch before: ${beforeInfo.epoch}`);
console.log(` Epoch after: ${afterAlice.epoch}`);
console.log(` Messages sent: 3`);
console.log(` Key rotations: 1`);
console.log('🎉 Key rotation complete!');
await alice.destroy();
await bob.destroy();
}
keyRotationDemo();
Expected Output:
🔄 MLS Key Rotation Demo
✅ Group setup complete
Step 1: Messages before key rotation...
Epoch: 1
Tree Hash: a3f2c8d9e1b4...
Alice: "Message before rotation"
Bob: "Message before rotation"
Step 2: Alice initiates key rotation...
✅ Alice rotated keys
✅ Bob processed commit
Step 3: Verify epoch progression...
Alice:
Epoch: 2
Tree Hash: 7e9d2f4a8c3b...
Bob:
Epoch: 2
Tree Hash: 7e9d2f4a8c3b...
✅ Both members synchronized at new epoch!
Step 4: Messages after rotation...
Alice: "Message after rotation"
Bob: "Message after rotation"
Bob: "Rotation successful!"
Alice: "Rotation successful!"
Step 5: Forward secrecy verification...
⚠️ Old keys cannot decrypt new messages
⚠️ Compromise of old keys does not affect current messages
✅ Forward secrecy maintained!
📊 Summary:
Epoch before: 1
Epoch after: 2
Messages sent: 3
Key rotations: 1
🎉 Key rotation complete!
Tutorial 3: Dynamic Membership
This tutorial shows adding and removing members from an active group.
async function dynamicMembershipDemo() {
console.log('👥 MLS Dynamic Membership Demo\n');
// ============================================
// STEP 1: Setup initial 2-person group
// ============================================
console.log('Step 1: Setting up initial group...');
const alice = new MLSManager('alice@example.com');
const bob = new MLSManager('bob@example.com');
const charlie = new MLSManager('charlie@example.com');
const david = new MLSManager('david@example.com');
await alice.initialize();
await bob.initialize();
await charlie.initialize();
await david.initialize();
const groupId = 'team-chat';
await alice.createGroup(groupId);
const bobKeyPackage = bob.getKeyPackage();
const addBob = await alice.addMembers(groupId, [bobKeyPackage]);
await bob.processWelcome(addBob.welcome, addBob.ratchetTree);
console.log('✅ Initial group: Alice + Bob');
console.log(` Epoch: ${(await alice.getGroupKeyInfo(groupId)).epoch}\n`);
// ============================================
// STEP 2: Add Charlie to existing group
// ============================================
console.log('Step 2: Adding Charlie...');
const charlieKeyPackage = charlie.getKeyPackage();
const addCharlie = await alice.addMembers(groupId, [charlieKeyPackage]);
// IMPORTANT: Bob must process the commit!
await bob.processCommit(groupId, addCharlie.commit);
// Charlie processes welcome
await charlie.processWelcome(addCharlie.welcome, addCharlie.ratchetTree);
console.log('✅ Charlie added');
console.log(` New epoch: ${(await alice.getGroupKeyInfo(groupId)).epoch}`);
// Verify synchronization
const aliceEpoch = (await alice.getGroupKeyInfo(groupId)).epoch;
const bobEpoch = (await bob.getGroupKeyInfo(groupId)).epoch;
const charlieEpoch = (await charlie.getGroupKeyInfo(groupId)).epoch;
console.log(` Alice epoch: ${aliceEpoch}`);
console.log(` Bob epoch: ${bobEpoch}`);
console.log(` Charlie epoch: ${charlieEpoch}`);
if (aliceEpoch === bobEpoch && bobEpoch === charlieEpoch) {
console.log('✅ All members synchronized!\n');
}
// ============================================
// STEP 3: Send messages in expanded group
// ============================================
console.log('Step 3: Testing expanded group...');
const msg1 = await alice.encryptMessage(groupId, 'Welcome Charlie!');
console.log('Alice: "Welcome Charlie!"');
console.log(` Bob received: "${await bob.decryptMessage(msg1)}"`);
console.log(` Charlie received: "${await charlie.decryptMessage(msg1)}"\n`);
const msg2 = await charlie.encryptMessage(groupId, 'Thanks everyone!');
console.log('Charlie: "Thanks everyone!"');
console.log(` Alice received: "${await alice.decryptMessage(msg2)}"`);
console.log(` Bob received: "${await bob.decryptMessage(msg2)}"\n`);
// ============================================
// STEP 4: Add David
// ============================================
console.log('Step 4: Adding David...');
const davidKeyPackage = david.getKeyPackage();
const addDavid = await alice.addMembers(groupId, [davidKeyPackage]);
// All existing members process commit
await bob.processCommit(groupId, addDavid.commit);
await charlie.processCommit(groupId, addDavid.commit);
// David processes welcome
await david.processWelcome(addDavid.welcome, addDavid.ratchetTree);
console.log('✅ David added');
console.log(` Group now has 4 members`);
console.log(` New epoch: ${(await alice.getGroupKeyInfo(groupId)).epoch}\n`);
// ============================================
// STEP 5: Test 4-person group
// ============================================
console.log('Step 5: Testing 4-person group...');
const msg3 = await david.encryptMessage(groupId, 'Hello team!');
console.log('David: "Hello team!"');
console.log(` Alice: "${await alice.decryptMessage(msg3)}"`);
console.log(` Bob: "${await bob.decryptMessage(msg3)}"`);
console.log(` Charlie: "${await charlie.decryptMessage(msg3)}"\n`);
console.log('✅ 4-person group working perfectly!');
// ============================================
// STEP 6: Remove Bob from group
// ============================================
console.log('\nStep 6: Removing Bob...');
const bobInfo = await alice.getGroupKeyInfo(groupId);
const bobIndex = 1; // Bob's leaf index in the tree (example)
const removeCommit = await alice.removeMembers(groupId, [bobIndex]);
// All remaining members process commit
await charlie.processCommit(groupId, removeCommit);
await david.processCommit(groupId, removeCommit);
console.log('✅ Bob removed from group');
console.log(` New epoch: ${(await alice.getGroupKeyInfo(groupId)).epoch}\n`);
// ============================================
// STEP 7: Bob cannot decrypt new messages
// ============================================
console.log('Step 7: Verifying Bob is removed...');
const msg4 = await alice.encryptMessage(groupId, 'Bob is gone');
try {
await bob.decryptMessage(msg4);
console.log('❌ ERROR: Bob should not be able to decrypt!');
} catch (error) {
console.log('✅ Bob cannot decrypt (expected behavior)');
}
console.log(` Charlie: "${await charlie.decryptMessage(msg4)}"`);
console.log(` David: "${await david.decryptMessage(msg4)}"\n`);
console.log('📊 Final state:');
console.log(' Members: Alice, Charlie, David');
console.log(' Removed: Bob');
console.log(' Forward secrecy: ✅ Maintained');
console.log('🎉 Dynamic membership demo complete!');
await alice.destroy();
await bob.destroy();
await charlie.destroy();
await david.destroy();
}
dynamicMembershipDemo();
P2P Integration: Real-World Usage
Now let's see how MLS is used in a production P2P application using WebRTC.
MLSProvider: Transparent Encryption Layer
The MLSProvider component wraps PeerJS connections with automatic MLS encryption:
// Reference: ../p2p/src/core/MLSProvider.tsx
import { MLSManager } from 'cryptography/Cryptography';
import PeerProvider from './PeerProvider';
export default function MLSProvider({
peerId,
children,
enableEncryption = true,
enableCascadingCipher = false,
cipherLayers = ['MLS', 'Signal', 'AES']
}) {
const [mlsManager, setMLSManager] = useState(null);
const [encryptionReady, setEncryptionReady] = useState(false);
const groupId = 'p2p-encrypted-chat';
// Initialize MLS
useEffect(() => {
if (!enableEncryption) return;
const initializeMLS = async () => {
const manager = new MLSManager(`${peerId}@p2p.local`);
await manager.initialize();
setMLSManager(manager);
};
initializeMLS();
}, [peerId, enableEncryption]);
// Intercept messages for encryption
const encryptMessage = async (plaintext, targetPeerId) => {
if (!mlsManager) return plaintext;
const envelope = await mlsManager.encryptMessage(groupId, plaintext);
return {
encrypted: true,
envelope: {
groupId: Array.from(envelope.groupId),
ciphertext: Array.from(envelope.ciphertext),
timestamp: envelope.timestamp
}
};
};
// Intercept messages for decryption
const decryptMessage = async (fromPeer, encryptedData) => {
if (!mlsManager || !encryptedData.encrypted) {
return encryptedData;
}
const envelope = {
groupId: new Uint8Array(encryptedData.envelope.groupId),
ciphertext: new Uint8Array(encryptedData.envelope.ciphertext),
timestamp: encryptedData.envelope.timestamp
};
const plaintext = await mlsManager.decryptMessage(envelope);
return plaintext;
};
return (
<MLSContext.Provider value={{
mlsManager,
encryptionReady,
encryptMessage,
decryptMessage
}}>
<PeerProvider peerId={peerId}>
{children}
</PeerProvider>
</MLSContext.Provider>
);
}
Key Features:
- Transparent encryption: No changes to existing message handling code
- Automatic group management: Creates group on first connection
- Key package exchange: Via WebRTC data channels
- Commit distribution: Ensures all peers stay synchronized
useMLSMessaging Hook
For React applications, a custom hook simplifies MLS usage:
// Reference: ../p2p/src/crypto/useMLSMessaging.js
export const useMLSMessaging = (peerId) => {
const [mlsManager, setMLSManager] = useState(null);
const [groupId, setGroupId] = useState(null);
const [ready, setReady] = useState(false);
// Initialize MLS
const initializeMLS = useCallback(async () => {
const manager = new MLSManager(peerId);
await manager.initialize();
setMLSManager(manager);
return manager;
}, [peerId]);
// Create group
const createGroup = useCallback(async (groupName) => {
const gid = groupName || `group-${Date.now()}`;
await mlsManager.createGroup(gid);
setGroupId(gid);
setReady(true);
return gid;
}, [mlsManager]);
// Encrypt message
const encryptMessage = useCallback(async (plaintext) => {
const envelope = await mlsManager.encryptMessage(groupId, plaintext);
return {
encrypted: true,
envelope: {
groupId: Array.from(envelope.groupId),
ciphertext: Array.from(envelope.ciphertext),
timestamp: envelope.timestamp
}
};
}, [mlsManager, groupId]);
// Decrypt message
const decryptMessage = useCallback(async (encryptedData) => {
const envelope = {
groupId: new Uint8Array(encryptedData.envelope.groupId),
ciphertext: new Uint8Array(encryptedData.envelope.ciphertext),
timestamp: encryptedData.envelope.timestamp
};
return await mlsManager.decryptMessage(envelope);
}, [mlsManager]);
return {
ready,
initializeMLS,
createGroup,
encryptMessage,
decryptMessage
};
};
Usage Example:
function ChatApp() {
const { initializeMLS, createGroup, encryptMessage, decryptMessage, ready } =
useMLSMessaging('user@example.com');
useEffect(() => {
initializeMLS().then(() => createGroup('my-chat'));
}, []);
const sendMessage = async (text) => {
const encrypted = await encryptMessage(text);
peer.send(encrypted);
};
const handleReceive = async (data) => {
const plaintext = await decryptMessage(data);
console.log('Received:', plaintext);
};
return ready ? <ChatUI onSend={sendMessage} /> : <Loading />;
}
Cascading Cipher: Defense in Depth
For maximum security, MLS can be combined with Signal Protocol and AES:
// Reference: ../p2p/CASCADING_CIPHER_IMPLEMENTATION.md
import {
CascadingCipherManager,
MLSCipherLayer,
SignalCipherLayer,
AESCipherLayer
} from 'cryptography/CascadingCipher';
// Setup cascading cipher
const cascadingCipher = new CascadingCipherManager();
// Layer 1: MLS (group security)
const mlsLayer = new MLSCipherLayer(mlsManager, groupId);
cascadingCipher.addLayer(mlsLayer);
// Layer 2: Signal Protocol (forward secrecy)
const signalLayer = new SignalCipherLayer(signalWasm, myIdentity);
cascadingCipher.addLayer(signalLayer);
// Layer 3: AES (fast symmetric encryption)
const aesLayer = new AESCipherLayer(password);
cascadingCipher.addLayer(aesLayer);
// Encrypt through all layers
const encrypted = await cascadingCipher.encrypt(plaintext);
// Plaintext → MLS → Signal → AES → Ciphertext
// Decrypt in reverse
const decrypted = await cascadingCipher.decrypt(encrypted);
// Ciphertext → AES → Signal → MLS → Plaintext
Security Benefits:
- MLS Layer: RFC 9420 group security, TreeKEM
- Signal Layer: Double Ratchet, post-compromise security
- AES Layer: Fast baseline encryption
- Defense in Depth: Compromise of one layer doesn't break entire system
WebRTC Integration
MLS works seamlessly with WebRTC data channels:
// Setup WebRTC connection
const peer = new Peer(myPeerId);
const conn = peer.connect(remotePeerId);
conn.on('open', async () => {
// Exchange key packages
const myKeyPackage = mlsManager.getKeyPackage();
conn.send({ type: 'key-package', package: myKeyPackage });
// Wait for remote key package
const remoteKeyPackage = await waitForKeyPackage(conn);
// If initiator, create group and add remote
if (isInitiator) {
await mlsManager.createGroup(groupId);
const { welcome, commit } = await mlsManager.addMembers(groupId, [remoteKeyPackage]);
conn.send({ type: 'welcome', welcome, ratchetTree });
}
});
conn.on('data', async (data) => {
if (data.type === 'welcome') {
// Join the group
await mlsManager.processWelcome(data.welcome, data.ratchetTree);
} else if (data.type === 'message' && data.encrypted) {
// Decrypt received message
const plaintext = await mlsManager.decryptMessage(data.envelope);
console.log('Received:', plaintext);
}
});
// Send encrypted message
async function sendMessage(text) {
const envelope = await mlsManager.encryptMessage(groupId, text);
conn.send({ type: 'message', encrypted: true, envelope });
}
