npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@vanishing.page/webcrypto-mls

v0.0.17

Published

MLS in the browser

Downloads

6,582

Readme

Webcrypto MLS

tests types module semantic versioning Common Changelog install size GZip size license

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.

Install

npm i -S @vanishing.page/webcrypto-mls

Fork

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:

  1. generateKeyPackage for each client. This runs locally on each client's device, before they know what group they'll join.
  2. createGroup to start a group, or joinGroup to join via a welcome message.
  3. createCommit to add or remove members or rotate keys. See commit messages.
  4. createApplicationMessage and processPublicMessage to 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:

  1. the signature keypair - this signs their future group messages/proposals.
  2. HPKE initial keypair - used only once, to decrypt the group secrets in the welcome message.
  3. 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  // Uint8Array

Security 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. defaultClientConfig carries failClosedAuthenticationService, whose validateCredential throws a UsageError rather 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 own AuthenticationService that 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; only createGroup, joinGroup, and joinGroupExternal take it.

Four more defaults worth knowing about:

  • Padding hides less than you might assume. paddingConfig defaults 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 }, adds n bytes to every message whatever its size, so ciphertext length stays an exact affine function of plaintext length and an observer just subtracts n: 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.

  • validateLifetimeOnReceive defaults to true. 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; set validateLifetimeOnReceive: false in LifetimeConfig to 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 whose credential.identity is 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 to validateCredential:

    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)
        }
    }

    prior is the credential currently occupying the leaf being replaced. It is supplied for Update proposals, for a commit's UpdatePath leaf, and when a received ratchet tree is revalidated. It is undefined for an Add, for an external commit joining at a blank leaf, and for the external_senders and GroupInfo signer checks, which replace nothing.

  • retainKeysForGenerations: 0 retains nothing. KeyRetentionConfig defaults to keeping 10 generations of message keys and 4 epochs, so a message that arrives out of order can still be decrypted. Lowering retainKeysForGenerations narrows that window; setting it to 0 keeps 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 UpdatePath or a Welcome must 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_key must 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.
  • joinGroup and joinGroupExternal check the external_senders extension of the group being joined against the caller's own AuthenticationService, so a joiner never inherits an external signer it would have refused. joinGroup also checks that the GroupInfo's protocol version matches the KeyPackage's; only the ciphersuite was compared before.
  • Once a commit removes this client, processMessage refuses 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 R is 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 a ValidationError: 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 replacing s with n - s. WebCrypto accepts both forms, so the noble provider does too; enforcing low-s in 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.

screenshot of 7 member group

What happens:

  1. 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).
  2. 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.
  3. 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

  1. Plaintext metadata, signed
  • group ID
  • epoch
  • event (Desmond rotated keys)
  • Desmond's new leaf public key
  1. 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)  // true

Persistence

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 joinGroup again -- that needs a fresh Welcome. The restored ClientState is 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 persist method so the browser does not evict your indexedDB group 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 advance

In 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 current ClientState in the group.
  • cipherSuite -- the CiphersuiteImpl from getCipherSuite.
  • pskIndex -- optional resolver for any pre-shared keys referenced by the proposals. Defaults to an index derived from state.
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's ClientState in the new epoch. Assign this back over your old state; the commit is not applied to context.state in place.
  • welcome -- a Welcome message for any newly added members, or undefined when the commit adds no members.
  • commit -- the wire-format MLSMessage to send to the group. It can be handed directly to processMessage on 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:

  1. Symmetric-key (KDF) ratchet -- the "hash ratchet." For each message you advance a chain key: chainKey_{n+1} = KDF(chainKey_n), and messageKey_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 learns chainKey_n can compute all future chain keys.
  2. 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

  1. Generate fresh secrets along their path to the root.
  2. Encrypt each new path secret to the sibling subtree using that subtree's public key.
  3. 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:

  1. TreeKEM produces the root secret - commitSecret. The UpdatePath in the Commit lets every member derive the same new secret at the root of the ratchet tree. That root value is called the commitSecret. It's the "fresh entropy the attacker doesn't have" -- the PCS/healing ingredient.

  2. The key schedule is MLS's fixed recipe of HKDF calls that turns one epoch's secrets into the next. commitSecret isn't used raw -- it's combined (mixed) with the previous epoch's initSecret (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.

  3. epochSecret -> new encryptionSecret. epochSecret is the master secret for the epoch. From it, MLS derives a whole family of labeled child secrets with DeriveSecret(epochSecret, "<label>").

    encryptionSecret is 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's initSecret are 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-wing


Some 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 the secretTree, which hands out per-sender message keys.
  • exporterSecret -- lets applications derive their own secrets from the group (via mlsExporter).
  • 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. joinGroupFromReinit and joinGroupFromBranch refuse 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