mls-ts
v0.1.0
Published
End-to-end encrypted group messaging for Node.js and the browser, using the MLS protocol (RFC 9420).
Maintainers
Readme
mls-ts
End-to-end encrypted group messaging for JavaScript and TypeScript.
mls-ts adds secure group chat to your Node.js and web apps. It implements MLS — Messaging Layer Security, the IETF standard (RFC 9420) for end-to-end encrypted group messaging, with forward secrecy and post-compromise security — behind a small, typed API: create an MlsClient, form a Group, and send and receive encrypted messages. The cryptography runs on the endpoints, so a server only ever sees ciphertext.
- Node.js — synchronous API, ready to use.
- Browser — the same API, loaded via WebAssembly (see
examples/web). - React Native — use the companion package
mls4rn(seeexamples/react-native).
Same audited cryptography everywhere: mls-ts wraps OpenMLS — the Rust reference implementation of RFC 9420 — compiled to WebAssembly. No cryptography is reimplemented in JavaScript.
Status: functional prototype. The API is still evolving and it isn't hardened for production yet — see Limitations. Developed with support from Prototype Fund.
Goals
- Provide a TypeScript-friendly interface for MLS group messaging primitives.
- Support web, Node.js, and React Native integration paths.
- Keep the project understandable for contributors who are new to MLS.
- Maintain the project as free and open-source software.
Installation
npm install mls-tsThe compiled WebAssembly is bundled with the package, so no Rust toolchain is required to use it.
Usage
Each participant is an MlsClient that owns its own in-memory key material. Create or join a Group, then send and receive end-to-end encrypted messages.
import { MlsClient } from "mls-ts";
// Two participants (each models a separate device).
const alice = new MlsClient("alice");
const bob = new MlsClient("bob");
// Alice founds a group and adds Bob from his published key package.
const group = alice.createGroup("chess-club");
const add = group.add(bob.keyPackage());
// Bob joins from the welcome + ratchet tree Alice produced.
const bobGroup = bob.joinGroup(add.welcome, add.ratchetTree);
// End-to-end encrypted application messages.
const ciphertext = group.send("hello bob");
console.log(bobGroup.receiveText(ciphertext)); // => "hello bob"Adding members and distributing messages
Group.add() commits the change locally and returns everything you need to distribute:
| Field | Give it to | Via |
| --- | --- | --- |
| welcome + ratchetTree | the new member | client.joinGroup(welcome, ratchetTree) |
| proposal then commit | other existing members | group.receive(proposal) then group.receive(commit) |
Transporting these between processes/devices is up to your application — every value is a plain Uint8Array.
API summary
MlsClient
new MlsClient(name)— create an in-memory participant with its own provider + identity.MlsClient.open(id, adapter): Promise<MlsClient>— open a persistent client, restoring a prior snapshot or starting fresh.save(): Promise<void>— persist the current state (persistent clients only).keyPackage(): Uint8Array— a serialized key package to publish so others can add you.createGroup(groupId): Group— found a new group.joinGroup(welcome, ratchetTree, groupId?): Group— join from an add's output; passgroupIdto make it reloadable after a restart.group(groupId): Group | undefined— get a group handle by id (e.g. afteropenrestores it).
Group
add(keyPackage): { welcome, ratchetTree, proposal, commit }— add a member (commits + applies locally).send(message: string | Uint8Array): Uint8Array— encrypt an application message.receive(bytes): Uint8Array— process a message; returns plaintext for application messages, empty for handshakes.receiveText(bytes): string | null— likereceive, decoded as UTF-8;nullfor handshakes.exportRatchetTree(): Uint8Array— serialize the ratchet tree (for joiners).exportKey(label, context, length): Uint8Array— derive an exported group secret.
Helpers: encodeUtf8, decodeUtf8, toHex, fromHex. The raw generated bindings are also re-exported as raw for advanced use.
Persistence
By default a client is in-memory and forgets everything when the process exits. For persistence across restarts, open a client with a StorageAdapter and save() after operations you want to keep:
import { MlsClient, FileStorageAdapter } from "mls-ts";
const adapter = new FileStorageAdapter("./data");
// session 1
const alice = await MlsClient.open("alice", adapter);
const group = alice.createGroup("room");
// ... add members, send / receive ...
await alice.save();
// session 2 (after a restart): the same id + adapter restores the session
const alice2 = await MlsClient.open("alice", adapter);
const group2 = alice2.group("room"); // resumes where it left offThe StorageAdapter interface is a tiny async load/save key–value store, so you can target other platforms (IndexedDB on web, AsyncStorage on React Native) by supplying your own. Only the Node FileStorageAdapter ships today.
Demo
A runnable, narrated three-party demo (alice, bob, charlie forming a group and exchanging messages):
npm run demo # smoke demo (three parties form a group, exchange messages)
npm run demo:present # narrated encrypted group chat, with restart persistence
npm run demo:files # encrypted file sharing (per-file key derived from the group)The browser and React Native demos live under examples/web and examples/react-native. For a full demo runbook covering all three targets — talking points, exact commands, and a Q&A cheat sheet — see docs/presentation.md.
Development
The wrapper depends only on the prebuilt WebAssembly committed under wasm/. The OpenMLS Rust source is vendored under openmls/ as committed build input, so the wasm can be rebuilt from a clean clone — see openmls/VENDOR.md. Only npm run build:wasm uses it, and only that step needs a Rust toolchain.
npm install # install dev dependencies + link the local wasm package
npm run typecheck # type-check the source
npm test # run the vitest suite
npm run build # emit dist/ (JS + type declarations)
npm run build:wasm # regenerate wasm/ from the vendored Rust source (needs Rust + wasm-pack)Repository structure
.
├── src/ # TypeScript facade (MlsClient, Group, helpers) + demo
├── tests/ # vitest end-to-end tests
├── wasm/ # committed openmls-wasm build artifact (bundled on publish)
├── scripts/ # build-wasm.sh (regenerates wasm/)
├── openmls/ # vendored OpenMLS Rust source (committed build input; see VENDOR.md)
├── docs/ # project notes and documentation
├── CONTRIBUTING.md # contribution guidelines
├── CODE_OF_CONDUCT.md # community expectations
├── SECURITY.md # security reporting policy
├── LICENSE # MIT License
└── README.md # project overviewLimitations
This is a prototype. Current known constraints:
- Async on web and React Native — the Node target is synchronous; the browser target loads WebAssembly via
init(), and React Native additionally round-trips through a hidden WebView bridge (Hermes has no WebAssembly runtime). Seemls4rnfor its specific constraints. - Persistence is opt-in and coarse — without an adapter, clients are in-memory and forget everything on exit. With a
StorageAdapter,save()writes a full snapshot of the client's storage each time (simple, not incremental), and snapshots hold private keys unencrypted at rest unless your adapter encrypts them. Only a Node file adapter ships. - Single fixed ciphersuite —
MLS_128_DHKEMX25519_CHACHA20POLY1305_SHA256_Ed25519. - Rough edges — malformed input to
receive()currently surfaces as a thrown wasm error rather than a clean typed error.
Contributing
Contributions, questions, and feedback are welcome. Please read CONTRIBUTING.md before opening issues or pull requests.
Security
Please do not report security issues in public GitHub issues. See SECURITY.md for the current reporting process.
License
This project is licensed under the MIT License. See LICENSE.
It builds on OpenMLS (also MIT), whose compiled WebAssembly is bundled with this package. Redistributed third-party licenses are reproduced in THIRD_PARTY_NOTICES.md.
