End-to-end encryption
The server is a blind, ordered relay. It stores and routes opaque key material and ciphertext, and never holds a key or a plaintext — which also means it cannot do anything clever with the content.
The approach #
Rather than inventing cryptography, Ollacore is the server side of protocols other people have
audited. Two client schemes are supported. MLS (RFC 9420) is the primary one:
the server is its Delivery Service — a KeyPackage directory and an ordered, blind relay for the
mls.* message kinds — and your client runs an MLS library such as OpenMLS. The
Megolm/Olm transport used by Matrix clients is also supported, through the
/v1/e2ee/* endpoints, for clients that want its ready-made Android and web packages.
Every member of a room must use the same scheme; the server cannot translate between them.
Encryption is set per room at creation and is immutable thereafter. A room is either encrypted or it is not; it cannot be downgraded later.
First: mint the token with a device_id #
/v1/e2ee/* endpoint refuses a token that has no explicit
device_id. Encryption is per device, so the server will not let you
publish or claim keys under an ambiguous identity. This is the single most common reason a
first E2EE integration returns 400 on every call.
POST /v1/server/session-tokens
{ "room_id": "0f5c…", "principal_id": "alice", "device_id": "pixel-8-abc" }
^^^^^^^^^^^^^^^^^^^^^^^^^
# Without device_id EVERY /v1/e2ee/* call fails:
# 400 { "code": "invalid_request",
# "message": "e2ee endpoints require a token minted with an explicit device_id" } Key distribution #
POST /v1/e2ee/keys/upload
{ "device_id": "pixel-8-abc",
"device_keys": { "algorithms": [...], "keys": { "curve25519:pixel-8-abc": "…" } },
"one_time_keys": { "signed_curve25519:AAAA": { "key": "…" } } }
# Discover devices — Matrix shape: a map of principal → device ids ([] = all)
POST /v1/e2ee/keys/query { "device_keys": { "alice": [], "bob": ["pixel-8-abc"] } }
POST /v1/e2ee/keys/claim { "one_time_keys": { "bob": { "pixel-8-abc": "signed_curve25519" } } }
Note the keys/query shape: it is a map of principal → device ids
(Matrix's device_keys), not a list of principals. An empty array means "all devices
for this principal".
| Property | Guarantee |
|---|---|
| Atomic issuance | Concurrent claims for the same device receive different keys — never the same one twice. |
| Fallback key | When a device's one-time keys run out, sessions remain establishable rather than failing. |
| Replenishment signalling | Clients are told their key pool is depleting so they can top it up before exhaustion. |
| Device discovery | New devices are announced so existing members re-fetch and include them. |
| Access control | Key retrieval requires sharing an active room with the target principal. |
To-device messages #
POST /v1/e2ee/todevice
{ "event_type": "m.room_key",
"messages": { "bob": { "pixel-8-abc": { …content… } } } } # "*" = all of bob's devices
GET /v1/e2ee/todevice # fetch queued messages; ack to delete event_type is required and sits alongside messages; the sender is
stamped from your token, never taken from the body.
Encrypted key material is deleted only after the recipient acknowledges it, so a client crash cannot permanently lose the ability to decrypt. Undelivered messages for a device that never comes back are aged out after 30 days.
MLS key packages
POST /v1/keypackages
{ "device_id": "pixel-8-abc",
"packages": [ "<base64 MLS key package>", … ] } # the field is "packages"
GET /v1/keypackages/count
GET /v1/rooms/{room_id}/keypackages/{principal_id} # consume one, atomically
The MLS directory. A device publishes KeyPackages; a member who wants to add a principal fetches
one package per device of theirs through the room-scoped route, which consumes it
— concurrent adders never receive the same package — and both parties must be members of that
room. The Commit and the Welcome then travel as ordinary mls.commit and
mls.welcome messages, in the room's sequence order, which is the delivery-service
guarantee an MLS client relies on to process commits in order.
Forward secrecy #
Membership changes emit durable member.added / member.removed events. Your
client rotates the outbound group session on removal — which is what makes a removed member
provably unable to decrypt anything sent afterwards. The server cannot do this for you; it is a
client obligation the events exist to trigger.
Messages in an encrypted room #
Only the encrypted kinds are accepted: mls.commit, mls.proposal,
mls.application, mls.welcome. The body carries ciphertext. Sending a
plaintext kind is refused with a conflict rather than silently accepted.
Deliberate degradation #
In encrypted rooms the server refuses features it cannot honour, rather than quietly weakening privacy:
- Search and reactions are rejected — both need plaintext.
- Push for a message is forced content-free: no sender, no preview. A call push still names the caller — who is calling is metadata, not message content — except on Web Push when the deployment runs content-free mode, which withholds it too.
- Thumbnails are not generated; send an encrypted micro-thumbnail as a second attachment.
- Attachment verification falls back to size and checksum only.
- Messages critical to the cryptographic chain cannot be edited or deleted.
- Recording of any kind is inherently incompatible with an E2EE room: a relay that cannot read the media cannot store it. Nothing offered today records a call.
Encrypted calls #
Zero server changes required: browsers encrypt each frame with a key derived from the group session using insertable streams, and the SFU forwards frames opaquely — it never needed to see them. This is client work only.