MainSDK
Search⌘K
Interfaces

Security and privacy

End-to-end encryption

e2e

Encrypt messages and records on people's devices with keys only they hold, using MLS for groups, so the server can't read them.

Building for 1.2ships as a experimental APIPhase 3 · Mobile and regulatedSpec e2eFree in your app, Apache-2.0

Status 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.
Stability levels
Updated 2026-10-28

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: []

Calls

  • 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.

main.yamlMLS (RFC 9420) (default)
e2e:
  driver: mls

Change this one line to swap providers; your calls stay the same.MLS integration

Codes

CodeWhere it appearsMeaning
MAIN-E2E-0007Checkmainsdk check, before deployThe post-quantum hybrid suite was asked for before spec post-quantum is in place (501)
MAIN-E2E-0008Checkmainsdk check, before deployA field in e2e.fields is read by server-side search, moderation or analytics, which skip it; e2e.accept silences it
MAIN-E2E-0009Checkmainsdk check, before deployThe 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-0001RuntimeError responses and logsSee the spec
MAIN-E2E-0002RuntimeError responses and logsSee the spec
MAIN-E2E-0003RuntimeError responses and logsSee the spec
MAIN-E2E-0004RuntimeError responses and logsSee the spec
MAIN-E2E-0005RuntimeError responses and logsSee the spec
MAIN-E2E-0006RuntimeError responses and logsSee the spec
MAIN-E2E-0010RuntimeError responses and logsSee the spec
MAIN-E2E-0011RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
ContractPhase 31.2e2e#groups
Sealed fields skipped by searchPhase 31.2e2e#skip
Key recovery with passkeysPhase 31.2e2e#recovery
MLS groups for messages and records (RFC 9420 driver)Phase 31.2e2e#mls-driver
Post-quantum hybrid ciphersuitePhase 51.6post-quantum#hybrid-kex

Quick start

main.yaml
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 these
app/notes.ts
import { 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 column

Configuration

KeyWhat it does
e2e.drivermls (@mainsdk/e2e-mls, RFC 9420) or local, the development fake
e2e.suiteclassic. pq-hybrid is refused with MAIN-E2E-0007 until the post-quantum spec is in place
e2e.recoverypasskey: recovery with the passkey PRF extension
e2e.fieldsmodel: [field] for the fields you encrypt, so check can name the trade-offs
e2e.acceptsearch, 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

CallWhat 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

app/leave.ts
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-0004

Recover on a new phone

The app asks the passkey for its PRF output with WebAuthn and passes a function that returns it.

app/recover.ts
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 keys

What 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

CodeMeaning
MAIN-E2E-0001e2e used before configureE2e
MAIN-E2E-0002No such group on this device
MAIN-E2E-0003Invalid request: a bad id, too many members, data over 4 MiB, a member with no key package
MAIN-E2E-0004Can't decrypt: no key for that epoch (removed, or added later), changed, or another group
MAIN-E2E-0005The passkey gave no PRF output
MAIN-E2E-0006Nothing to recover with this passkey
MAIN-E2E-0007Post-quantum hybrid suite asked for before it is available (also a check)
MAIN-E2E-0008Check: an encrypted field is read by search, moderation or analytics
MAIN-E2E-0009Check and runtime: the driver is not for production
MAIN-E2E-0010A suite or feature the driver does not implement
MAIN-E2E-0011You are not a member of the group
MAIN-E2E-0012Two members committed for the same epoch and the retries are used up; try again

Swapping drivers

DriverProvider
mlsMLS (RFC 9420, suite 0x0001), @mainsdk/e2e-mlsthe real driver; not production until the audit signs off
localGroup key ratchet on WebCrypto, @mainsdk/testingdevelopment 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 mls driver keeps each past epoch's secret so old messages decrypt (retain, default all). Inside a retained epoch there is no forward secrecy. Removed members are still locked out of later epochs.
  • The mls driver 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). Set keepRecords: false to turn it off, and then a second decrypt of 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 local driver 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?

↑↓ moveEnter openEsc close

Not decided yet