Build it

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 #

Every /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".

PropertyGuarantee
Atomic issuanceConcurrent claims for the same device receive different keys — never the same one twice.
Fallback keyWhen a device's one-time keys run out, sessions remain establishable rather than failing.
Replenishment signallingClients are told their key pool is depleting so they can top it up before exhaustion.
Device discoveryNew devices are announced so existing members re-fetch and include them.
Access controlKey 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.

The client library is the real cost. The backend transport is complete and tested against real cryptographic clients, but integrating the crypto stack — group state persistence, device add/remove UX, key backup — is a substantial client project. Plan it as its own track, and note the Android/web adapter is a port that still has to be written.