End-to-end encryption
e2eEncrypt messages and records on people's devices with keys only they hold, using MLS for groups, so the server can't read them.
e2eFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
e2e encrypts messages and records on people's devices with keys only they hold, so your server stores ciphertext it cannot read. Groups use MLS. Reach for it for a private chat or notes.
At a glance
e2e:
driver: mls
suite: classic
recovery: passkey
fields:
notes: [title, body]
accept: []import { configureE2e, e2e, toWire } from "mainsdk/experimental/e2e";
import { localE2eDriver, memoryE2eRelay } from "@mainsdk/testing";
const relay = memoryE2eRelay(); // the server side; development only, with the local driver (a fake)
await configureE2e({ self: "bob", driver: localE2eDriver, relay }).join(); // bob's device publishes its key package
configureE2e({ self: "alice", driver: localE2eDriver, relay });
const group = await e2e.group.create({ members: ["bob"] }); // keys stay on the devices
const sealed = await e2e.encrypt(group.id, "meet at six"); // the server stores ciphertext only
const note = toWire(sealed); // a string that starts e2e1., safe in a columnCalls
e2e.join()e2e.group.create({ members })e2e.group.add(groupId, member)e2e.group.remove(groupId, member)e2e.group.get(groupId)e2e.encrypt(groupId, data)e2e.decrypt(groupId, message)e2e.recovery.enrol(prf)e2e.recovery.restore(prf)
Guarantees
Whatever the driver.
- Private keys never leave the device
- The server stores and relays ciphertext only
- Adding or removing a member changes the epoch, and a removed member can't read anything new
- Group membership changes are signed and audited
- Recovery uses the passkey PRF extension, with nothing the server can read
Drivers
2 drivers. Select one to see its line in main.yaml.
e2e:
driver: mlsChange this one line to swap providers; your calls stay the same.MLS integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-E2E-0007 | Checkmainsdk check, before deploy | The post-quantum hybrid suite was asked for before spec post-quantum is in place (501) |
MAIN-E2E-0008 | Checkmainsdk check, before deploy | A field in e2e.fields is read by server-side search, moderation or analytics, which skip it; e2e.accept silences it |
MAIN-E2E-0009 | Checkmainsdk check, before deploy | The driver is not for production: local in production, mls without @mainsdk/e2e-mls, or a driver whose production is false in production (500) |
MAIN-E2E-0001 | RuntimeError responses and logs | See the spec |
MAIN-E2E-0002 | RuntimeError responses and logs | See the spec |
MAIN-E2E-0003 | RuntimeError responses and logs | See the spec |
MAIN-E2E-0004 | RuntimeError responses and logs | See the spec |
MAIN-E2E-0005 | RuntimeError responses and logs | See the spec |
MAIN-E2E-0006 | RuntimeError responses and logs | See the spec |
MAIN-E2E-0010 | RuntimeError responses and logs | See the spec |
MAIN-E2E-0011 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Contract | Phase 3 | 1.2 | e2e#groups |
| Sealed fields skipped by search | Phase 3 | 1.2 | e2e#skip |
| Key recovery with passkeys | Phase 3 | 1.2 | e2e#recovery |
| MLS groups for messages and records (RFC 9420 driver) | Phase 3 | 1.2 | e2e#mls-driver |
| Post-quantum hybrid ciphersuite | Phase 5 | 1.6 | post-quantum#hybrid-kex |
Quick start
e2e:
driver: mls # @mainsdk/e2e-mls; local is the development fake
suite: classic
recovery: passkey
fields:
notes: [title, body] # encrypted end to end: search, moderation and analytics skip theseimport { configureE2e, e2e, toWire } from "mainsdk/experimental/e2e";
import { localE2eDriver, memoryE2eRelay } from "@mainsdk/testing";
const relay = memoryE2eRelay(); // the server side; in your app it is a few routes of your own server
await configureE2e({ self: "bob", driver: localE2eDriver, relay }).join(); // bob's device publishes its key package
configureE2e({ self: "alice", driver: localE2eDriver, relay });
const group = await e2e.group.create({ members: ["bob"] }); // keys stay on the devices
const sealed = await e2e.encrypt(group.id, "meet at six"); // the server stores ciphertext only
const note = toWire(sealed); // a string that starts e2e1., safe in a columnConfiguration
| Key | What it does |
|---|---|
e2e.driver | mls (@mainsdk/e2e-mls, RFC 9420) or local, the development fake |
e2e.suite | classic. pq-hybrid is refused with MAIN-E2E-0007 until the post-quantum spec is in place |
e2e.recovery | passkey: recovery with the passkey PRF extension |
e2e.fields | model: [field] for the fields you encrypt, so check can name the trade-offs |
e2e.accept | search, moderation or analytics: trade-offs you accept, which silences MAIN-E2E-0008 |
What you get
- Private keys never leave the device.
- The server stores and relays ciphertext only.
- Adding or removing a member changes the epoch, and a removed member can't read anything new.
- Group membership changes are signed and audited.
- Recovery uses the passkey PRF extension, with nothing the server can read.
Adding or removing a member moves the group to a new epoch with fresh keys, and a removed member can't read anything new. A person who loses every device gets their keys back with their passkey.
Calls
| Call | What it does |
|---|---|
e2e.join() | Publishes this device's key package to the relay so others can add its member to a group. |
e2e.group.create({ members }) | Creates a group at epoch 0 for you and the listed members and returns it with its id. |
e2e.group.add(groupId, member) | Adds a member and moves the group to a new epoch. The change is signed and audited. |
e2e.group.remove(groupId, member) | Removes a member and moves to a new epoch the removed member can't read. |
e2e.group.get(groupId) | Returns the group as this device sees it: its epoch and members. |
e2e.encrypt(groupId, data) | Encrypts data for the group, on the device, and returns the sealed message to store. |
e2e.decrypt(groupId, message) | Decrypts a sealed message, on a device that holds its epoch's keys. |
e2e.recovery.enrol(prf) | Stores the device's keys, encrypted under a key from the passkey's PRF output, on the relay. |
e2e.recovery.restore(prf) | On a new device, gets the keys back with the same passkey. |
Examples
Remove a member
import { createE2e } from "mainsdk/experimental/e2e";
import { localE2eDriver, memoryE2eRelay } from "@mainsdk/testing";
const relay = memoryE2eRelay();
const alice = createE2e({ self: "alice", driver: localE2eDriver, relay });
const bob = createE2e({ self: "bob", driver: localE2eDriver, relay });
const carol = createE2e({ self: "carol", driver: localE2eDriver, relay });
await bob.join();
await carol.join();
const group = await alice.group.create({ members: ["bob", "carol"] });
await alice.group.remove(group.id, "carol"); // epoch 1: carol gets nothing new
const sealed = await alice.encrypt(group.id, "carol is out");
await bob.decrypt(group.id, sealed); // bob reads it
await carol.decrypt(group.id, sealed).catch((e) => e.code); // MAIN-E2E-0004Recover on a new phone
The app asks the passkey for its PRF output with WebAuthn and passes a function that returns it.
import { createE2e, type PrfSource } from "mainsdk/experimental/e2e";
import { localE2eDriver, memoryE2eRelay } from "@mainsdk/testing";
// In the browser: navigator.credentials.get({ publicKey: { ..., extensions: { prf: { eval: { first: salt } } } } })
declare const prf: PrfSource;
const relay = memoryE2eRelay();
const oldPhone = createE2e({ self: "bob", driver: localE2eDriver, relay });
await oldPhone.join();
await oldPhone.recovery.enrol(prf); // keys, encrypted under the passkey's PRF output
const newPhone = createE2e({ self: "bob", driver: localE2eDriver, relay });
await newPhone.recovery.restore(prf); // same passkey, same keysWhat the server can't do
A field encrypted end to end is a string that starts e2e1.. The server holds no key, so:
- Search does not index it.
- Moderation does not send it to a classifier. A recipient who holds the key files a report instead.
- Analytics does not record it as a prop.
mainsdk check names this trade-off for each field in e2e.fields that a search index, moderate or an analytics event would read (MAIN-E2E-0008). Keep the field out of that feature, or add the feature to e2e.accept once you accept it.
Errors and checks
| Code | Meaning |
|---|---|
MAIN-E2E-0001 | e2e used before configureE2e |
MAIN-E2E-0002 | No such group on this device |
MAIN-E2E-0003 | Invalid request: a bad id, too many members, data over 4 MiB, a member with no key package |
MAIN-E2E-0004 | Can't decrypt: no key for that epoch (removed, or added later), changed, or another group |
MAIN-E2E-0005 | The passkey gave no PRF output |
MAIN-E2E-0006 | Nothing to recover with this passkey |
MAIN-E2E-0007 | Post-quantum hybrid suite asked for before it is available (also a check) |
MAIN-E2E-0008 | Check: an encrypted field is read by search, moderation or analytics |
MAIN-E2E-0009 | Check and runtime: the driver is not for production |
MAIN-E2E-0010 | A suite or feature the driver does not implement |
MAIN-E2E-0011 | You are not a member of the group |
MAIN-E2E-0012 | Two members committed for the same epoch and the retries are used up; try again |
Swapping drivers
| Driver | Provider | |
|---|---|---|
mls | MLS (RFC 9420, suite 0x0001), @mainsdk/e2e-mls | the real driver; not production until the audit signs off |
local | Group key ratchet on WebCrypto, @mainsdk/testing | development only; not MLS, not for real data |
Testing
Use localE2eDriver and memoryE2eRelay from @mainsdk/testing. The relay records every byte it is given as relay.seen, so a test can prove no plaintext reached the server. No network is used.
Your relay
Your relay must keep one global order (a seq) across a group's handshake messages. Members apply the first valid commit for an epoch in that order; a member whose commit lost applies the winner's and retries, up to three times, then fails with MAIN-E2E-0012. A relay that reorders handshakes can fork a group.
Security and privacy
- The
mlsdriver keeps each past epoch's secret so old messages decrypt (retain, defaultall). Inside a retained epoch there is no forward secrecy. Removed members are still locked out of later epochs. - The
mlsdriver keeps the plaintext of every record it sealed or opened in its state, so a record can be read again (MLS message keys work once). SetkeepRecords: falseto turn it off, and then a seconddecryptof the same record fails. The cache sits in device state, protected only by the passkey recovery blob when exported, and it grows without bound. This is awaiting the owner's sign-off. - After you restore state on a new device, the first seal, add or remove rotates the member's keys.
- The contract epoch is the MLS epoch minus one: a new group is epoch 0.
- The driver does not yet build external commits, PSK, ReInit, X.509 credentials, more than one device per member or key transparency; those refuse with
MAIN-E2E-0010. - The driver's state is exported and imported by the driver; your app provides the store.
- The
localdriver is not MLS: no TreeKEM, no credentials (key packages are trusted on first use) and no per-message forward secrecy inside an epoch. Never use it for real data. - The server still sees who is in a group and when messages are sent.
- A removed member keeps what they already read; end-to-end encryption can't unsend it.
- Key handling for the rest of the app is described in Keys, and general cryptography in Crypto.
FAQ
Can I use it in production? Not yet. The MLS driver is built and tested against the official vectors, but it stays production: false until an external audit signs off, and check refuses both drivers in production. The gap is recorded as debt in spec e2e.
Is it post-quantum safe? No. A hybrid post-quantum suite comes only once the post-quantum spec is in place.
Next
Was this page helpful?