@vanishing.page/webcrypto-mls
v0.0.17
Published
MLS in the browser
Downloads
6,582
Maintainers
Readme
Webcrypto MLS
MLS (RFC 9420) for the browser.
MLS is a protocol for end-to-end encrypted group messaging.
It keeps a shared secret in sync across a group of participants as members join
and leave, and does not cause an O(n) blowup in the number of
messages or keys.
This implementation uses the webcyrpto API, which means it is usable in the browser.
- See a live demo
- See the
example-realistic-demofolder, deployed to Cloudflare.
- Install
- Fork
- Example
- Security Properties
- Scenarios
- Encrypted attachments
- API
- Ratchet
- Root Secret
- Defaults
- Some Terms
- Forward secrecy and post-compromise security
- Develop
- Modules
- Use
- See Also
Install
npm i -S @vanishing.page/webcrypto-mlsFork
This is a fork of LukaJCB/ts-mls.
Example
See ./example, or the deployed page. It is a webpage with controls for all the mechanics of MLS.
The core flow:
generateKeyPackagefor each client. This runs locally on each client's device, before they know what group they'll join.createGroupto start a group, orjoinGroupto join via a welcome message.createCommitto add or remove members or rotate keys. See commit messages.createApplicationMessageandprocessPublicMessageto send and receive encrypted group messages.
An application message is the spec's term for arbitrary user data sent
through the group (as opposed to a proposal or
commit, which carry protocol control data).
In MLS (RFC 9420),
group membership and keying material never change
unilaterally. Every change to the group goes through a two-phase
propose-then-commit cycle. This codebase's
proposal.ts and commit.ts
are the wire types for those two phases.
The tree KEM structure is used for efficient updates of key material. It is not used in any way by application code. That is, an incoming message can be encrypted to the whole group, not a subtree of the group.
Joining the Group
Before joining a group, the new group member must generate a KeyPackage
locally. The key package contains 3 keypairs:
- the signature keypair - this signs their future group messages/proposals.
- HPKE initial keypair - used only once, to decrypt the group secrets in the welcome message.
- HPKE leaf-node keypair - used consistently as their position in the group's ratchet tree
The pending new group member must share the public half of all three keypairs
(the KeyPackage object). The key package object is signed by their signature
key. The public KeyPackage is wrapped in an Add proposal, which brings them
into the group.
The initial HPKE keypair is single-use for the sake of "forward secrecy,"
meaning that, if we used a persistent keypair, then the encrypted GroupSecrets
in the Welcome message stays decryptable by anyone who has that keypair.
When a committer adds a new member via an Add proposal, the committer HPKE-encrypts the GroupSecrets to the init public key, and that ciphertext goes out in the Welcome message. The new member decrypts it once, with the init private key, to bootstrap into the group.
import {
generateKeyPackage,
defaultCapabilities,
defaultLifetime,
getCipherSuite,
createGroup,
createCommit,
joinGroup,
createApplicationMessage,
processMessage,
makePskIndex,
acceptAll
} from '@vanishing.page/webcrypto-mls'
// use the default ciphersuite given no arguments
const cipherSuite = await getCipherSuite(
// DEFAULT_CIPHERSUITE -- 'MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519'
// defaultCryptoProvider -- 'src/crypto/implementation/default/provider.js'
)
// 1. generateKeyPackage for each client
const alice = await generateKeyPackage(
{
credentialType: 'basic',
identity: new TextEncoder().encode('alice')
},
defaultCapabilities(),
defaultLifetime(),
[],
cipherSuite
)
const bob = await generateKeyPackage(
{ credentialType: 'basic', identity: new TextEncoder().encode('bob') },
defaultCapabilities(),
defaultLifetime(),
[],
cipherSuite
)
// 2. createGroup to start a group
const groupId = cipherSuite.rng.randomBytes(32)
let aliceState = await createGroup(
groupId,
alice.publicPackage,
alice.privatePackage,
[],
cipherSuite
)
// 3. createCommit to add a member
const { newState, welcome } = await createCommit(
{ state: aliceState, cipherSuite },
{
extraProposals: [{
proposalType: 'add',
add: { keyPackage: bob.publicPackage }
}],
wireAsPublicMessage: true,
ratchetTreeExtension: true
}
)
aliceState = newState
// bob joins from the welcome message
let bobState = await joinGroup(
welcome!,
bob.publicPackage,
bob.privatePackage,
makePskIndex(undefined, {}),
cipherSuite
)
// 4. createApplicationMessage and processPublicMessage to send
// and receive encrypted group messages
const {
newState: aliceAfterSend,
privateMessage
} = await createApplicationMessage(
aliceState,
new TextEncoder().encode('hello, bob'),
cipherSuite,
new Uint8Array(0)
)
aliceState = aliceAfterSend
const result = await processMessage(
{ wireformat: 'mls_private_message', privateMessage },
bobState,
makePskIndex(bobState, {}),
acceptAll,
cipherSuite
)
if (result.kind === 'applicationMessage') {
bobState = result.newState
console.log(new TextDecoder().decode(result.message)) // "hello, bob"
}The result also says who sent the message, as MLS authenticated it.
result.sender is { senderType, leafIndex } -- for an application
message, senderType is 'member' and leafIndex is the sender's leaf
in the ratchet tree -- and result.authenticatedData is the
authenticatedData the sender passed to createApplicationMessage.
Attribute messages by these fields, not by transport metadata: the
transport can lie, the signature cannot.
When the message is a commit that was accepted, the newState result
carries committer in the same shape. A member commit reports
senderType: 'member' and the committer's leaf index; an external
commit reports senderType: 'new_member_commit' and the leaf the joiner
was placed in. committer is absent for proposals and rejected commits.
Use with pre-existing keypairs
If you need to use a signature key that you've already generated (for example,
to maintain a persistent identity across sessions), you can pass a pre-existing
Ed25519 CryptoKeyPair to generateKeyPackage via the signatureKeyPair
option.
The private key can be non-extractable, meaning it is never readable.
[!TIP] Use the persist method to tell the browser not to delete a keypair from
indexedDB.
import {
generateKeyPackage,
defaultCapabilities,
defaultLifetime,
getCipherSuite
} from '@vanishing.page/webcrypto-mls'
const cipherSuite = await getCipherSuite()
// Generate a non-extractable Ed25519 keypair
// (pass `true` instead of `false` to make it extractable if you need to
// persist the private key)
const sigKeyPair = await crypto.subtle.generateKey(
{ name: 'Ed25519' },
false, // <-- not extractable
['sign', 'verify']
)
// Pass it to generateKeyPackage via the signatureKeyPair option
const alice = await generateKeyPackage(
{
credentialType: 'basic',
identity: new TextEncoder().encode('alice')
},
defaultCapabilities(),
defaultLifetime(),
[],
cipherSuite,
{ signatureKeyPair: sigKeyPair }
)
// The private package now holds your CryptoKey, not raw bytes
alice.privatePackage.signaturePrivateKey // CryptoKey
// The leaf node's public key is the raw export of the public half
alice.publicPackage.leafNode.signaturePublicKey // Uint8ArraySecurity Properties
MLS gives us
forward secrecy
(a stolen key can't decrypt past messages)
and
post-compromise security
(the group automatically heals to a secure state after a compromise).
Membership changes atomically rekey the group at O(log N) cost, not O(N).
In short: a naive scheme leaks all past and future traffic the moment one key
leaks and never recovers, whereas MLS continuously limits the blast radius
of any compromise in time and scales to large groups efficiently.
Security Considerations
There is one decision the library refuses to make for you:
You must choose an
AuthenticationService.defaultClientConfigcarriesfailClosedAuthenticationService, whosevalidateCredentialthrows aUsageErrorrather than guess whether a peer's signature key is really bound to their claimed identity. Nothing in MLS establishes that binding; it comes from outside (a CA for x509 credentials, an out-of-band directory for basic credentials), so a production application supplies its ownAuthenticationServicethat performs a real check. Without one, a malicious peer joins under any identity it likes.For local testing and demos there is
unsafeAcceptAllAuthenticationService, which accepts everything. It is deliberately not a default -- you have to name it:import { createGroup, defaultClientConfig, unsafeAcceptAllAuthenticationService } from '@vanishing.page/webcrypto-mls' const state = await createGroup(groupId, keyPackage, privateKeys, [], cs, { ...defaultClientConfig, authService: unsafeAcceptAllAuthenticationService })The chosen config travels with the state, so
createCommit,processMessage, and the resumption helpers all reuse it; onlycreateGroup,joinGroup, andjoinGroupExternaltake it.
Four more defaults worth knowing about:
Padding hides less than you might assume.
paddingConfigdefaults to{ kind: 'padUntilLength', padUntilLength: 256 }, which pads every message shorter than 256 bytes out to 256 bytes. Messages under the floor are indistinguishable from each other by length; a message at or above the floor is not padded at all, so its length is visible to a passive observer up to a constant framing overhead. The other mode,{ kind: 'alwaysPad', paddingLength: n }, addsnbytes to every message whatever its size, so ciphertext length stays an exact affine function of plaintext length and an observer just subtractsn: it hides no length information at all, and exists to buy a fixed overhead rather than privacy. Neither mode hides anything but length. Epoch, content type, and the fact and timing of every message are visible on the wire regardless.validateLifetimeOnReceivedefaults totrue. A KeyPackage leaf node's lifetime window is checked both when this client generates it and when it arrives from a peer, so an expired or improbably long-lived peer credential is rejected rather than silently accepted. That check reads the local clock, so a client whose clock is badly wrong will refuse otherwise-valid peers; setvalidateLifetimeOnReceive: falseinLifetimeConfigto opt out and check only the leaf nodes this client generated.Identity continuity is your call. MLS does not require the identity at a leaf to stay the same across an Update or a commit's
UpdatePath. A leaf's self-signature binds only the group ID and the leaf index, so without an application-level check, any member can send an Update whosecredential.identityis some other member's identity and be attributed that identity by every peer from then on -- impersonation from inside the group. To enforce continuity, use the third argument tovalidateCredential:import type { AuthenticationService } from '@vanishing.page/webcrypto-mls' const authService:AuthenticationService = { async validateCredential (credential, signaturePublicKey, prior) { if (!(await boundToIdentity(credential, signaturePublicKey))) { return false } // nothing is being replaced -- an Add, or an external join at a // blank leaf if (prior === undefined) return true return sameIdentity(prior, credential) } }prioris the credential currently occupying the leaf being replaced. It is supplied for Update proposals, for a commit'sUpdatePathleaf, and when a received ratchet tree is revalidated. It isundefinedfor an Add, for an external commit joining at a blank leaf, and for theexternal_sendersand GroupInfo signer checks, which replace nothing.retainKeysForGenerations: 0retains nothing.KeyRetentionConfigdefaults to keeping 10 generations of message keys and 4 epochs, so a message that arrives out of order can still be decrypted. LoweringretainKeysForGenerationsnarrows that window; setting it to0keeps no skipped-generation keys at all, which is the strongest forward secrecy this library offers and drops any message that arrives after a later one from the same sender.
What the library checks
These are enforced regardless of configuration. They are listed because each one rejects something that used to be accepted, so a peer that does not follow RFC 9420 closely may now be refused where it previously got through.
- A path secret received in an
UpdatePathor aWelcomemust derive to the public key the sender advertised for each node on the path (RFC 9420 7.5, 12.4.3.1). Without this a committer can hand one member a path that silently does not match the tree, splitting the group. - A new or rotated leaf's
encryption_keymust not equal any other key in the tree, parent nodes included, and two Adds in one commit are compared against each other rather than only against the pre-commit tree. - A new leaf must support every credential type already in use in the group, and every member must support the new leaf's -- RFC 9420 7.3 in both directions.
joinGroupandjoinGroupExternalcheck theexternal_sendersextension of the group being joined against the caller's ownAuthenticationService, so a joiner never inherits an external signer it would have refused.joinGroupalso checks that the GroupInfo's protocol version matches the KeyPackage's; only the ciphersuite was compared before.- Once a commit removes this client,
processMessagerefuses all further inbound traffic for that state rather than processing it against a frozen epoch. - A commit whose only proposals are custom (numeric) types still requires
an
UpdatePath, so post-compromise security is not skipped. - Unmerged-leaf validation constrains only the nodes between the unmerged leaf and the parent under inspection. The previous whole-path rule rejected RFC-valid trees, which blocked joins entirely.
- Ed25519 and Ed448 signatures are verified strictly (RFC 8032), under
the noble provider as well as WebCrypto, so a signature with a
non-canonical
Ris refused by every member rather than by some. A LeafNode or KeyPackage whose signature key is the identity point or any other small-order point is refused with aValidationError: with such a key, one fixed signature verifies for every message.
All of these throw a ValidationError, or a CryptoVerificationError
when a signature or MAC is what failed, or a CodecError when the bytes
will not decode at all. All three subclass MlsError, so catching that
one covers a rejected message. An InternalError means the library found
a bug in itself; if remote input ever produces one, that is worth
reporting.
One property is documented rather than enforced:
- ECDSA signatures are malleable in
s. On the P-256, P-384 and P-521 suites, anyone holding a valid signature can produce a second valid one over the same bytes by replacingswithn - s. WebCrypto accepts both forms, so the noble provider does too; enforcing low-sin one provider alone would fork a group that mixes them. A signature therefore does not identify a message. Never key anything -- a deduplication cache, a message ID, a replay filter -- on a hash of a signature or of the signed bytes that contain one. Key on the content instead, for example the proposal reference or the epoch and sender.
Scenarios
Key Rotation
There are 7 members of our group, and Desmond wants to rotate his keys.

What happens:
- Desmond generates a fresh leaf key and derives new secrets for every node on his direct path to the root: nodes 6 (Desmond) -> 5 -> 3 -> 7. That's the "4 keys" in the status panel, roughly log(N).
- For each of those path nodes, Desmond encrypts the new node secret to the
public key of the sibling subtree (the copath). So:
- new secret for node 5 -> encrypted to Carl (node 4)
- new secret for node 3 -> encrypted to the {Alice, Bob} subtree (node 1)
- new secret for node 7 (root) -> encrypted to the {Eloise, Fran, Glorg} subtree (node 11) Each group member can decrypt exactly the secrets for the nodes they sit under, no others.
- Every member now knows the new root secret. From that they each locally derive the new epoch secret, and from that the group AES key. Nobody sends the group key over the wire. Each member computes an identical value for the root key independently.
What data is transferred?
The rotating user, Desmond needs to send some data. He runs createCommit
with no proposals (an empty commit), which the library treats as a
self-update: fresh leaf key, re-keyed direct path. He gets a commit message,
which needs to be delivered to every other group member.
[!NOTE] The
O(log N)number refers to the size of the message, not the number of recipients.
Everyone who receives the commit message passes it to processMessage.
The commit message contains
- Plaintext metadata, signed
- group ID
- epoch
- event (Desmond rotated keys)
- Desmond's new leaf public key
- HPKE-encrypted path secrets -- the new secret for each node on Desmond's
direct path (6 -> 5 -> 3 -> 7) -- each encrypted to the sibling
subtree's public key
- node 5's new secret -> encrypted to Carl (node 4)
- node 3's new secret -> encrypted to the {Alice, Bob} subtree (node 1)
- root (node 7) secret -> encrypted to the {Eloise, Fran, Glorg} subtree (node 11)
One signed commit, broadcast once, from the rotating member to everyone else. The per-recipient targeting (who can decrypt which path secret) is baked into that single message by HPKE -- your transport just needs to deliver the same bytes to all members.
Not Transferred
We don't need to transfer the group key / epoch secret.
They never cross the wire. Each group member independently derives the new root
secret from the one path secret they can decrypt, then locally computes the
identical epoch secret and group AES key. The README verifies convergence by
comparing epochAuthenticator values, a public value that
matches only if the underlying secret keys match.
Key Rotation Example Code
Rotating keys is just an empty commit -- a commit with no proposals. When a member commits nothing, the library sees there is no membership change and instead treats it as a self-update: it generates a fresh leaf key and derives new secrets for every node on the committer's direct path to the root.
Here Desmond rotates. He broadcasts the resulting commit message to the
group. Each member applies it and independently derives the new epoch. The
only thing that travels over the wire is the commit -- the group key itself is
never transmitted.
The commit message is signed and authenticated, but not encrypted. Anyone who can see the wire can read its contents. The commit message contains the group id, epoch, that Desmond rotated, and the user's new leaf public key. The actual secrets in the commit are HPKE-encrypted to the tree inside the commit.
See createCommit.
import {
createCommit,
processMessage,
makePskIndex,
acceptAll
} from '@vanishing.page/webcrypto-mls'
// `desmondState` is Desmond's ClientState in the existing group.
// `memberStates` are the ClientStates of the other 6 members.
// 1. Desmond commits with no proposals. With nothing to add or remove,
// createCommit rotates his leaf key and re-keys his whole direct path.
const { newState: desmondNext, commit } = await createCommit(
{ state: desmondState, cipherSuite },
{ wireAsPublicMessage: true } // see src/create-commit.ts:111
)
desmondState = desmondNext
// Desmond sends `commit` to the group over whatever transport you use. It is
// already a wire-format MLSMessage, so a member can hand it straight to
// processMessage on the other end.
sendToGroup(commit)
// 2. Every other member receives that same commit message and processes it.
// Each one decrypts the new path secret for the nodes they sit under and
// re-derives the new root and epoch secrets locally.
const memberNextStates = await Promise.all(
memberStates.map(async (state) => {
const result = await processMessage(
commit, // the message received from Desmond
state,
makePskIndex(state, {}),
acceptAll,
cipherSuite
)
if (result.kind !== 'newState') {
throw new Error('expected a commit, got ' + result.kind)
}
return result.newState
})
)
// 3. Everyone converged on the same epoch. The epochAuthenticator is a public
// value derived from the (secret) epoch secret, so matching authenticators
// means matching group keys (doesn't exposing the key itself).
const bytesEqual = (a, b) => {
return a.length === b.length && a.every((byte, i) => byte === b[i])
}
const converged = memberNextStates.every((state) => {
return bytesEqual(
state.keySchedule.epochAuthenticator,
desmondState.keySchedule.epochAuthenticator
)
})
console.log(converged) // truePersistence
E2EE means we cannot rely on a server to save private keys for us. Instead,
persist the member's ClientState locally, then restore it when the app
starts again.
Most of ClientState is structured-clone friendly data -- Uint8Arrays,
records, Maps, and bigints -- plus the member's signaturePrivateKey.
The clientConfig field is the exception: its authService and
keyPackageEqualityConfig contain functions, so cloning a created state
directly throws DataCloneError. Omit clientConfig before writing the state,
then re-attach the same config after reading it back.
indexedDB uses the
structured clone algorithm,
which can hold a CryptoKey, including a non-extractable one, without
ever reading its raw bytes.
[!NOTE] "Re-joining" here does not mean running
joinGroupagain -- that needs a freshWelcome. The restoredClientStateis your membership. You are already in the group at the epoch you left off at.
Use a non-extractable signatureKeyPair (see
Use with pre-existing keypairs)
so the key persisted alongside the state can never be exported.
[!TIP] Call the
persistmethod so the browser does not evict yourindexedDBgroup state under storage pressure.
Whenever the state advances, like after createApplicationMessage,
createCommit, or processMessage, write the new state to the DB so the
saved state knows the group's current epoch:
import { createApplicationMessage } from '@vanishing.page/webcrypto-mls'
// `saveState` is application code -- see the indexedDB helpers below.
const { newState, privateMessage } = await createApplicationMessage(
aliceState,
new TextEncoder().encode('hello, bob'),
cipherSuite,
new Uint8Array(0)
)
aliceState = newState
await saveState(groupId, aliceState) // persist after every advanceIn a new session, restore the state. The cipher suite is stateless,
so re-derive it with getCipherSuite:
import {
createApplicationMessage,
getCipherSuite
} from '@vanishing.page/webcrypto-mls'
const cipherSuite = await getCipherSuite()
// Use the same config that was passed to createGroup/joinGroup.
let aliceState = await loadState(groupId, clientConfig)
if (aliceState === undefined) throw new Error('no saved group state')
// If other members advanced the group while you were away, feed those
// messages through processMessage first so your epoch catches up, saving
// after each one. Once current, send as usual.
const { newState, privateMessage } = await createApplicationMessage(
aliceState,
new TextEncoder().encode('back online'),
cipherSuite,
new Uint8Array(0)
)
aliceState = newState
await saveState(groupId, aliceState)indexedDB Helpers
import {
type ClientConfig,
type ClientState
} from '@vanishing.page/webcrypto-mls'
// Minimal indexedDB helpers keyed by groupId (hex string)
function saveState (groupId:string, state:ClientState):Promise<void> {
const { clientConfig: _clientConfig, ...persistableState } = state
return new Promise((resolve, reject) => {
const open = indexedDB.open('mls', 1)
open.onupgradeneeded = () => open.result.createObjectStore('groups')
open.onerror = () => reject(open.error)
open.onsuccess = () => {
const tx = open.result.transaction('groups', 'readwrite')
// structured clone keeps the non-extractable signature CryptoKey
tx.objectStore('groups').put(persistableState, groupId)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
}
})
}
function loadState (
groupId:string,
clientConfig:ClientConfig
):Promise<ClientState | undefined> {
return new Promise((resolve, reject) => {
const open = indexedDB.open('mls', 1)
open.onupgradeneeded = () => open.result.createObjectStore('groups')
open.onerror = () => reject(open.error)
open.onsuccess = () => {
const tx = open.result.transaction('groups', 'readonly')
const get = tx.objectStore('groups').get(groupId)
get.onsuccess = () => {
const state = get.result as
Omit<ClientState, 'clientConfig'> | undefined
resolve(state === undefined ? undefined : {
...state,
clientConfig
})
}
get.onerror = () => reject(get.error)
}
})
}Catching Up
In the Persistence section, we mentioned feeding past messages
through processMessage first so you are in the correct epoch.
While you were offline, other members may have committed changes (adds,
removes, key rotations), advancing the group past the epoch your restored
ClientState left off at.
Your state must be in the current epoch to send messages. Each commit derives the next epoch from the one before it, so the buffered messages have to be applied in the order the group produced them. MLS rejects a message whose epoch does not line up with the state you apply it to, so an out-of-order or skipped commit throws rather than silently corrupting your state.
The return value from processMessage has a kind field with two
possible values. A commit or proposal comes back as { kind: 'newState' }.
A commit is what actually moves you to the next epoch.
An application message comes back as
{ kind: 'applicationMessage' } with the decrypted bytes. Both carry an
advanced newState to assign back over your old one.
[!NOTE] Your transport/relay is responsible for handing back messages in the correct order.
Catch Up Example
import {
processMessage,
createApplicationMessage,
makePskIndex,
acceptAll
} from '@vanishing.page/webcrypto-mls'
// `state` is the ClientState you restored with loadState().
// `loadState` is application code that reads from indexedDB.
// `pending` is the ordered list of wire-format messages that arrived
// while you were offline. Your transport/relay is responsible for
// handing them back in order.
let state = await loadState(groupId)
if (state === undefined) throw new Error('no saved group state')
for (const message of pending) {
const result = await processMessage(
message,
state,
makePskIndex(state, {}),
acceptAll,
cipherSuite
)
if (result.kind === 'applicationMessage') {
// an application message someone sent in that epoch
console.log(new TextDecoder().decode(result.message))
}
// both kinds carry an advanced state; a commit is what moves you to the
// next epoch. Save after each so a crash mid-catch-up can resume.
state = result.newState
await saveState(groupId, state)
}
// --------------------------------------------------------------------
// You are now caught up. `state` now sits at the group's current epoch,
// so it is ok to send messages again.
// --------------------------------------------------------------------
const { newState, privateMessage } = await createApplicationMessage(
state,
new TextEncoder().encode('caught up'),
cipherSuite,
new Uint8Array(0)
)
state = newState
await saveState(groupId, state)Encrypted attachments
Implements [Encrypted Attachments for MLS][attach-spec].
Attachments vs _
Why use an encrypted attachment vs encrypting a blob, then including the decryption key inside an MLS message?
Two things: where the key comes from, and how long it stays useful.
An attachment key is derived, never transferred. attachmentCek starts at
the current epoch's applicationExportSecret and walks sixteen levels of
ExpandWithLabel, one per bit of the 16 bit ComponentID, down to
the leaf that belongs to attachments.
The secret sitting at that leaf is the attachment's component secret: one
value per epoch, encrypting every attachment the group sends in that epoch. It
expands with the objectId to a 32 byte content encryption key, so each
object still gets a key of its own.
Every member of the epoch does the same walk and arrives at the same key. The key never has to be transferred. There is no second key exchange to run, and no per-file key to store, ship, or garbage collect.
A ComponentID is the 16 bit number that identifies one consumer of the MLS
extension API, drawn from an IANA (Internet Assigned Numbers Authority)
registry with a private-use range. Each component gets its own leaf of the
exporter tree, and the walk down to it is one way, so a component cannot
derive any other component's secrets from its own. Attachments use the
private-use value 0xF001 (ATTACHMENT_COMPONENT_ID),
until the attachments draft receives an IANA assignment.
The MLS message carries an AttachmentRef -- objectId, plaintext length,
a commitment, a locator -- and no key material at all.
Why not put it in the MLS tree
Putting the key in a message makes a copy of it, and that copy lives as long as the message does. Anyone holding that plaintext later -- a member you removed, a client that never pruned its local history, a backup, a device that gets seized -- can still fetch the ciphertext from the object store and read it. The blob's confidentiality ends up resting on message retention rather than on group membership.
Deriving the key ties it to the epoch, so attachments get forward
secrecy with epoch granularity. An attacker who compromises a key at
epoch N+3 cannot walk back to epoch N's applicationExportSecret,
and therefore cannot decrypt an attachment from epoch N.
A file is too big to send through the group as a message. Typically a file will
be encrypted, then go to an object store. The object store holds opaque
bytes and answers Range requests over them. It is not given a key, and it learns
nothing about the content. What travels through MLS is an AttachmentRef -- a
few dozen bytes -- small enough to ride in an application message.
That ref is also the integrity story. It travels signed, inside the MLS
message. The reader sees the ref, and they can also recompute the blob's
snapshot themselves, which lets them check that the given blob matches
the ref.snapshot in the MLS message.
The object store
You have to implement an object store in the application code (it is not part
of this library). The object store a blob backend such as S3 or a CDN.
The application uploads the ciphertext produced by encryptAttachmentForGroup,
keyed by the reference's objectId. The object store later supplies the
ciphertext stream to decryptAttachmentStreamForGroup.
Random Access
The application must turn the ciphertext ranges returned by
openAttachmentRangeForGroup into HTTP Range requests and pass each
response back to this library. The object store holds opaque ciphertext only.
It receives no CEK or group state and cannot decrypt the object. This library
does not provide an HTTP server, bucket adapter, or other storage backend.
memoryObjectStore in the example is a small in-memory implementation
of the expected interface.
Attachment Import
Attachments are opt in. You import from subpaths.
import {
encryptAttachmentForGroup
} from '@vanishing.page/webcrypto-mls/attachment/writer'
import {
decryptAttachmentStreamForGroup
} from '@vanishing.page/webcrypto-mls/attachment/reader'
import {
openAttachmentRangeForGroup
} from '@vanishing.page/webcrypto-mls/attachment/range'
import {
type AttachmentRef,
refFromAuthData,
refToAuthData
} from '@vanishing.page/webcrypto-mls/attachment/reference'Upload Example
Nothing here is a separate key exchange. The key that seals an attachment comes from the ratchet tree the group already maintains.
Every member's copy of the tree produces the same epoch secret, and the key
schedule expands that into, among other things, an applicationExportSecret.
That secret is the root of the safe-extension exporter tree: sixteen levels,
one leaf per ComponentID. Attachments take the leaf for their own
ComponentID, and that component secret plus the objectId expands to the 32
byte content encryption key. A reader runs the identical walk from their own
copy of the tree, so no key ever travels with the object, and the object
store never holds anything that would let it derive one.
Membership in the tree at that epoch is the entire access control story. Any leaf in the tree can recompute the CEK for any objectId in that epoch. A commit re-keys the direct path, which moves the epoch secret, which moves every attachment key derived beneath it. That is why a reference stops decrypting at the next commit (see Things to know), and that's why a member who joins at epoch N cannot read an attachment sealed in epoch N-1 even though the ciphertext is still sitting in the store.
ObjectStore, below, is the blob store interface, with methods put, get,
getRange, and size. Function decodePrivateMessage unwraps a wire
message down to its PrivateMessage. Function drain collects a
ReadableStream<Uint8Array> into one Uint8Array.
Every key comes from state.keySchedule. The xForGroup wrappers
derive the per-object content encryption key from the current epoch and
the objectId, use it, and wipe it. A ClientState is the only
secret an application holds.
Sealing and sending is one call plus an upload.
You can upload a stream instead of a buffer by calling .readable. To upload
a buffer, call .bytes.
import {
type CiphersuiteImpl,
type ClientState,
createApplicationMessage,
encodeMlsMessage
} from '@vanishing.page/webcrypto-mls'
import {
encryptAttachmentForGroup
} from '@vanishing.page/webcrypto-mls/attachment/writer'
import {
type AttachmentRef,
refToAuthData
} from '@vanishing.page/webcrypto-mls/attachment/reference'
export async function sendAttachment (
state:ClientState,
plaintext:Uint8Array,
caption:string,
store:ObjectStore,
cs:CiphersuiteImpl
):Promise<{
newState:ClientState,
wire:Uint8Array,
ref:AttachmentRef
}> {
// Any unique octets will do. The objectId is not secret; it binds
// the CEK to this one object, so reusing one across two files
// would reuse a key.
const objectId = new Uint8Array(16)
globalThis.crypto.getRandomValues(objectId)
const encrypted = await encryptAttachmentForGroup(
state.keySchedule,
objectId,
plaintext,
cs
)
await store.put(objectId, encrypted.readable)
const { newState, privateMessage } = await createApplicationMessage(
state,
new TextEncoder().encode(caption),
cs,
// add a reference to the encrypted blob
// the group is able to decrypt the blob
refToAuthData(encrypted.reference)
)
const wire = encodeMlsMessage({
privateMessage,
wireformat: 'mls_private_message',
version: 'mls10'
})
return { newState, wire, ref: encrypted.reference }
}Receive an Attachment
The blob reference is in the application message's authenticated data. The sender's signature authenticates it, and the delivery service can route on it. The blob reference is not hidden.
Processing a message verifies the signature.
The example's own decodePrivateMessage unwraps the wire bytes and
checks the format before anything else touches them:
import {
type PrivateMessage,
decodeMlsMessage
} from '@vanishing.page/webcrypto-mls'
function decodePrivateMessage (wire:Uint8Array):PrivateMessage {
const decoded = decodeMlsMessage(wire, 0)?.[0]
if (!decoded || decoded.wireformat !== 'mls_private_message') {
throw new Error('expected a private message')
}
return decoded.privateMessage
}import {
type CiphersuiteImpl,
type ClientState,
makePskIndex,
processPrivateMessage
} from '@vanishing.page/webcrypto-mls'
import {
decryptAttachmentStreamForGroup
} from '@vanishing.page/webcrypto-mls/attachment/reader'
import {
type AttachmentRef,
refFromAuthData
} from '@vanishing.page/webcrypto-mls/attachment/reference'
export async function receiveAttachment (
state:ClientState,
wire:Uint8Array,
store:ObjectStore,
cs:CiphersuiteImpl
):Promise<{
newState:ClientState,
caption:string,
ref:AttachmentRef,
plaintext:Uint8Array
}> {
const privateMessage = decodePrivateMessage(wire)
const result = await processPrivateMessage(
state,
privateMessage,
makePskIndex(state, {}),
cs
)
if (result.kind !== 'applicationMessage') {
throw new Error('expected an application message')
}
const ref = refFromAuthData(privateMessage.authenticatedData)
const plain = await decryptAttachmentStreamForGroup(
state.keySchedule,
ref,
store.get(ref.objectId),
cs
)
return {
newState: result.newState,
caption: new TextDecoder().decode(result.message),
ref,
plaintext: await drain(plain)
}
}For a seek, openAttachmentRangeForGroup reports the ciphertext ranges
that plaintext range needs. They are not contiguous, so they cannot be
collapsed into a single request. Turn each into a bytes=START-END
header, fetch them, and hand decrypt one stream per range in the same
order:
import {
type CiphersuiteImpl,
type ClientState
} from '@vanishing.page/webcrypto-mls'
import {
openAttachmentRangeForGroup
} from '@vanishing.page/webcrypto-mls/attachment/range'
import {
type AttachmentRef
} from '@vanishing.page/webcrypto-mls/attachment/reference'
export async function receiveAttachmentRange (
state:ClientState,
ref:AttachmentRef,
range:{ offset:number, length:number },
store:ObjectStore,
cs:CiphersuiteImpl
):Promise<{ bytes:Uint8Array, httpRanges:string[] }> {
const read = await openAttachmentRangeForGroup(
state.keySchedule, ref, range, cs
)
try {
const httpRanges = read.ranges.map(r => (
`bytes=${r.offset}-${r.offset + r.length - 1}`
))
const streams = httpRanges.map(header => (
store.getRange(ref.objectId, header)
))
return { bytes: await drain(read.decrypt(streams)), httpRanges }
} finally {
read.close()
}
}See example/attachment-end-to-end.ts. The test suite runs that file, and the code above is copied from it, with each snippet's imports gathered from the top of the file. For a version you can click through, see the attachments page of the live demo, wired up in example/attachments-demo.ts.
Things to know
The objectId is yours to keep unique. It is 1 to 255 octets, it is not secret, and it binds the CEK to one object. It must be unique within the epoch and must never be reused across epochs: two objects sharing an objectId in the same epoch share a key.
A reference dies at the next commit. The CEK comes from the epoch that sealed the object, so once the group commits, that object stops decrypting for everyone, the sender included. The failure is a plain decrypt failure, deliberately indistinguishable from a tampered object, so an application cannot tell the two apart and should not try. If an attachment has to outlive the epoch, then re-seal it in the new epoch.
Finish the stream or cancel it. The stream returned by
decryptAttachmentStreamForGroup owns the CEK and zeroes it when it
ends, errors, or is cancelled. Walking away from a partly read stream
runs none of those, and the key stays in memory until the stream is
collected. Read to the end, or cancel the reader.
close() is not optional, and it is final. A range read owns the
CEK it derived; close() is what zeroes it. Call it in a finally,
as above. It also makes the read single use: to seek again, open
another one.
Two plaintextLength types. AttachmentRef.plaintextLength is a
bigint, because the wire format carries a uint64. The lower level
openObject takes a number. Converting is the caller's job, and a
file large enough to lose precision in a number is one you should be
streaming anyway.
API
createCommit
import { createCommit } from '@vanishing.page/webcrypto-mls'Commit one or more proposals as an existing member, advancing the
group to a new epoch. The proposals can be buffered
ones already broadcast to the group plus any passed inline via extraProposals.
createCommit re-keys the committer's direct path in the ratchet
tree, derives the next epoch's secrets, and returns the
wire-format commit message to send to the group.
If the commit adds members, it also returns a Welcome
message for those new members to joinGroup with. With no proposals to add or
remove, the commit still rotates the committer's leaf key and re-keys their
direct path, which is how a member refreshes their key material on its own.
parameters
context
The MLS context for the committing member.
state-- the committer's currentClientStatein the group.cipherSuite-- theCiphersuiteImplfromgetCipherSuite.pskIndex-- optional resolver for any pre-shared keys referenced by the proposals. Defaults to an index derived fromstate.
options
All fields are optional.
wireAsPublicMessage
Wire the commit as an mls_public_message instead of the default encrypted
mls_private_message. Handshake messages are sent as public messages when a
recipient may not yet share the group's secrets. Defaults to false.
extraProposals
Proposals to include in this commit in addition to any already buffered on the
group. This is how you add or remove a member, or rotate a key, in the same call
that commits it. Defaults to [].
ratchetTreeExtension
Embed the full ratchet tree in the GroupInfo carried by the Welcome. New
members need the tree to join; set this when they cannot fetch it out of band.
Defaults to false.
groupInfoExtensions
Extra extensions to attach to the GroupInfo sent to new members. Defaults to
[].
authenticatedData
Additional authenticated data bound to the commit message but not encrypted.
Defaults to an empty Uint8Array.
returns
A CreateCommitResult:
newState-- the committer'sClientStatein the new epoch. Assign this back over your old state; the commit is not applied tocontext.statein place.welcome-- aWelcomemessage for any newly added members, orundefinedwhen the commit adds no members.commit-- the wire-formatMLSMessageto send to the group. It can be handed directly toprocessMessageon the receiving end.
Ratchet
The important part is the key ratchet. That gets us post-compromise-security (self-healing) and "forward secrecy" (a compromised key doesn't reveal past messages).
Double Ratchet
Signal's Double Ratchet algorithm is the classic. MLS is designed to give us similar security properties while also being efficient for large groups.
"Ratcheting" means deriving a new key from old ones for each message, and throwing away the old keys. In Signal, the two ratchets are:
- Symmetric-key (KDF) ratchet -- the "hash ratchet." For each message
you advance a chain key:
chainKey_{n+1} = KDF(chainKey_n), andmessageKey_n = KDF(chainKey_n). This is cheap and gives forward secrecy within a chain, but it's one-way only. It can't heal from a compromise, because an attacker who learnschainKey_ncan compute all future chain keys. - Diffie-Hellman ratchet -- the "asymmetric ratchet." Each party attaches a
fresh DH public key to messages. When you receive a new DH key from
your peer, you do a new DH computation and reseed the root key:
rootKey, chainKey = KDF(rootKey, DH(myNewPriv, theirPub)). This injects fresh entropy the attacker doesn't have, which is what provides PCS/healing.
In Signal, a DH ratchet turns on each direction change, meaning
A -> B vs B -> A, and the new DH-derived symmetric key seeds a new
symmetric chain. The symmetric ratchet turns per-message inside each chain.
MLS Ratchet
MLS separates the ratcheting into two pieces that map roughly onto Signal's two ratchets.
1. The message-level ratchet (forward secrecy)
This is a direct analog of Signal's symmetric KDF ratchet. Within a single epoch, each sender has a chain of keys derived from the secret tree.
The encryptionSecret in index.ts is the root of this.
It's the epoch secret from which each member's per-message ratchet chains
are seeded. It gives forward secrecy within an epoch and is one-way, exactly
like Signal's chain key. It does not heal on its own.
2. The epoch-level ratchet (post-compromise security)
This is the innovation of MLS. Instead of pairwise DH, MLS arranges members as leaves of a left-balanced binary tree where every node holds a keypair. Each member knows the private keys on the direct path from their leaf to the root.
When a member wants to inject fresh entropy (a "Commit", like healing or adding/removing a member), they
- Generate fresh secrets along their path to the root.
- Encrypt each new path secret to the sibling subtree using that subtree's public key.
- Broadcast one
UpdatePath.
Every member can decrypt the portion they need (because they hold keys in
exactly one subtree at each level) and derive the new root secret, which
becomes the new epoch's commitSecret.
When a member sends a commit, MLS runs a fixed sequence that produces
the next epoch's secrets:
TreeKEM produces the root secret -
commitSecret. TheUpdatePathin the Commit lets every member derive the same new secret at the root of the ratchet tree. That root value is called thecommitSecret. It's the "fresh entropy the attacker doesn't have" -- the PCS/healing ingredient.The key schedule is MLS's fixed recipe of HKDF calls that turns one epoch's secrets into the next.
commitSecretisn't used raw -- it's combined (mixed) with the previous epoch'sinitSecret(that's the link that chains epoch N to epoch N+1) plus a hash of the group context (group ID, epoch number, tree hash, etc.). Roughly:joinerSecret = KDF.Extract(initSecret_{prev}, commitSecret) epochSecret = KDF over (joinerSecret, groupContext, ...)This is what makes it a ratchet. The new epoch's master secret depends on both the old state and the fresh commit entropy.
epochSecret->new encryptionSecret.epochSecretis the master secret for the epoch. From it, MLS derives a whole family of labeled child secrets withDeriveSecret(epochSecret, "<label>").encryptionSecretis one of those children (label "encryption"). It's the root that seeds the secret tree, which in turn seeds each member's per-message hash ratchet.membershipKey,confirmationKey,exporterSecret, and the next epoch'sinitSecretare siblings derived the same way.
The chaining of initSecret epoch-to-epoch is the outer ratchet, and
the per-message secret tree under encryptionSecret is the inner one.
PCS in MLS is epoch-granular and per-path. Keys advance only when
a Commit happens, and full group healing after compromising member X
requires a Commit that refreshes X's path. This is the tradeoff versus
Signal's continuous per-message DH healing -- coarser in timing,
but O(log N) instead of O(N).
Root Secret
In the example app, the root nodes secret is shared by every member of the group, and each group member derives the secret independently.
What is distributed is a set of lower path secrets, and everyone hashes their way up from there to the same root.
If Alice wants to rotate keys, she derives a path secret for each node from her leaf up to the root. The key at each intermediate node is hashed from the one below it.
Defaults
With the default ciphersuite
(MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519), every primitive runs on the
Webcrypto API.
| Primitive | Default | Backend |
| ------------------------ | ---------------------------------- | ----------------------------- |
| Signature (Ed25519) | makeWebCryptoSignatureImpl | WebCrypto (subtle) |
| Hash / HMAC (SHA-256) | makeHashImpl | WebCrypto (subtle) |
| AEAD (AES-128-GCM) | makeAead | WebCrypto (subtle) |
| KDF (HKDF-SHA256) | @hpke/core HkdfSha256 | WebCrypto (HKDF via subtle) |
| HPKE KEM (DHKEM-X25519) | @hpke/core DhkemX25519HkdfSha256 | WebCrypto (subtle) |
This includes the X25519 key agreement in the HPKE KEM. @hpke/core
performs X25519 (and Ed25519) through crypto.subtle per the WICG Secure
Curves spec, with no @noble/curves fallback. The default path
requires a runtime whose WebCrypto implements X25519 and Ed25519
(Node 19+, recent Chrome, Safari, Firefox, Deno, Bun, and Cloudflare Workers).
On an older engine @hpke/core throws rather than falling back to a pure-JS
implementation.
Note that changing the signature algorithm away from Ed25519 switches
signing to @noble/curves (see make-signature-impl.ts), so the
all-WebCrypto guarantee applies specifically to the defaults.
Incoming message callback
processMessage takes a callback that decides whether to accept each
incoming proposal and commit. Each proposal it sees carries a
senderType -- member, external, new_member_proposal or
new_member_commit -- alongside senderLeafIndex.
Pass undefined and the library uses defaultIncomingMessageCallback,
which rejects a new_member_proposal and accepts everything else. A
new_member_proposal is a self-signed Add: it is authenticated only by
the proposer's own KeyPackage signature, and anyone who knows the
group's id and epoch (both cleartext) can send one. Accepting it by
default would let the next routine commit add an outsider and send them
a Welcome.
acceptAll accepts everything, including a new_member_proposal. Pass
it only if your application deliberately allows open self-add.
Optional Ciphersuite Dependencies
Non-default ciphersuites pull in extra packages that are declared as
optional peer dependencies -- install the ones you need, or you'll hit a
DependencyError at runtime when that ciphersuite is used:
| Package | Needed for |
| ---------------------------- | ---------------------------------------------------- |
| @noble/curves | Signature algorithms other than Ed25519 (e.g. P-256, secp256k1) |
| @noble/post-quantum | Post-quantum signature algorithms (e.g. ML-DSA) |
| @hpke/ml-kem | ML-KEM (post-quantum) HPKE KEM ciphersuites |
| @hpke/hybridkem-x-wing | The X-Wing hybrid KEM ciphersuite |
npm i -S @noble/curves @noble/post-quantum @hpke/ml-kem @hpke/hybridkem-x-wingSome Terms
Leaf-node Keypair
The leaf-node keypair is the HPKE keypair that represents a member's actual position in the group's ratchet tree. The ratchet tree is the binary tree structure MLS uses to derive and distribute the group's shared encryption keys.
The leaf-node keypair specifically is the bottom-most keypair in that structure.
Commits and Proposals
Membership and group changes (add a member, remove a member, rotate a key) are expressed as proposals, applied via a commit. A commit produces a new group epoch: a new shared secret derived from the old one plus the changes in the commit. Anyone who processes the commit message computes the same new epoch secret, so the group stays in sync without a server mediating the cryptography.
Proposal
A proposal is a single, standalone request to change the group state.
It doesn't take effect on its own; it just gets broadcast and buffered
(addUnappliedProposal in
create-message.ts:54)
until someone commits it. src/proposal.ts:20-90
shows the variants, each corresponding to one kind of change:
- Add -- bring a new member in, carrying their
KeyPackage - Update -- a member rotates their own leaf key material (
LeafNodeUpdate) - Remove -- evict a member by leaf index
- PSK -- inject an external pre-shared key into the key schedule
- Reinit -- restart the group under new parameters (version/ciphersuite/extensions)
- ExternalInit -- how an external joiner enters via an external commit
- GroupContextExtensions -- change the group's extension set
Pending proposals
A buffered proposal blocks createApplicationMessage until a commit
applies it (RFC 9420 behaviour). If none ever will -- the proposer left,
or the proposal can never be committed -- the application can list and
discard it. Both are pure functions over ClientState:
import {
listPendingProposals,
discardPendingProposal,
} from '@vanishing.page/webcrypto-mls'
// each entry: { ref, proposal, senderLeafIndex, senderType }
for (const p of listPendingProposals(state)) {
if (shouldDrop(p)) state = discardPendingProposal(state, p.ref)
}discardPendingProposal returns a new state and leaves its input
unchanged; an unknown reference returns the state as it was.
The pending set is capped by ClientConfig.maxPendingProposals
(default defaultMaxPendingProposals, 256). A proposal received or
created while that many are pending is rejected with a
ValidationError, and nothing already pending is evicted, so a flood
of outsider proposals cannot push out an honest one. A cap of 0 accepts
no proposals at all.
Commit
The commit is the message that actually applies a batch of proposals atomically
and advances the epoch. src/commit.ts:12-14 is deliberately small:
export interface Commit {
proposals:ProposalOrRef[]
path:UpdatePath | undefined
}The proposals field lists the changes this commit is applying. Each entry is
one of two shapes (see ProposalOrRef in
src/proposal-or-ref-type.ts:29-33):
{ proposalOrRefType: 'proposal', proposal }-- the full proposal inlined directly into the commit{ proposalOrRefType: 'reference', reference }-- just a hash pointing at a proposal that was already broadcast earlier and is sitting in the recipients' buffer of unapplied proposals
This lets a commit either introduce a brand-new proposal on the spot, or bundle up several previously-sent proposals by reference, without having to repeat their full contents.
The path field is separate from all of that -- it's not about which proposals are being applied, it's about giving the group fresh secrets. Every member sits at a leaf of a tree (the "ratchet tree"), and each leaf has a path of ancestor nodes up to the root. When you commit, you can generate a new secret for every node on your path from your leaf to the root, encrypt each one so only the members "below" that node can decrypt it, and attach the whole bundle as path (type UpdatePath). Once applied, everyone recomputes shared secrets from these new values. This is what gives MLS post-compromise security: even if someone's old key leaked, once they commit an update path, the leaked key no longer helps an attacker compute the new secrets.
path is optional because a commit doesn't strictly have to refresh keys
(e.g. a commit that's only removing a member already forces new secrets via
other means in some cases), but in practice committers usually include one.
Applying all of this -- processing the proposals and the path, then deriving
the next epoch's keys -- is what
src/create-commit.ts
does. It's the code that actually advances keySchedule and secretTree to
the next epoch.
Proposals declare what should change, and a commit is the message that says "apply these now" and re-derives the group's secrets.
Key Schedule
See src/key-schedule.ts.
The key schedule is how the epoch's root secret is converted to
all the specific keys the group needs. Every epoch has one epochSecret, and
function initializeKeySchedule runs it through a labeled HKDF derivation
function (deriveSecret(epochSecret, label, kdf)) once per output to produce
a fixed set of named secrets, each used for a different job:
senderDataSecret-- protects the sender-data header of encrypted messages.encryptionSecret-- seeds thesecretTree, which hands out per-sender message keys.exporterSecret-- lets applications derive their own secrets from the group (viamlsExporter).externalSecret-- supports external joins/commits.confirmationKey-- keys the confirmation tag that proves a commit was applied correctly.membershipKey-- authenticates public messages as coming from a member.resumptionPsk-- links this group to resumption (reinit/branch) flows.joinGroupFromReinitandjoinGroupFromBranchrefuse a Welcome that does not carry exactly one resumption PSK of the matching usage.epochAuthenticator-- a public per-epoch value every member derives identically; the example app shows it as the "shared epoch secret".initSecret-- the input that chains this epoch to the next one.
Epochs are advanced by initializeEpoch. It takes the previous epoch's
initSecret and the commitSecret (the root path secret produced by the
commit's UpdatePath, see Commit above), combines them
into a joinerSecret, extracts the next epochSecret, and derives a fresh
KeySchedule from it. Because each epoch's initSecret feeds the next
epoch's derivation, the epochs form a hash chain: knowing a later epoch's
secrets never lets you recompute an earlier one (forward secrecy).
create-commit.ts and process-messages.ts call initializeEpoch to move
the group's keySchedule forward whenever a commit is created or applied.
Epoch Authenticator
Epoch Secret
Key package
Before joining a group, a client publishes a key package
