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 speaks the transport protocol of a mature, independently audited stack — the Megolm/Olm implementation used by Matrix clients. Your client uses ready-made libraries (including prebuilt Android and web packages); the server provides only the routing endpoints.

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.

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

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.

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 is forced content-free: no sender, no preview.
  • 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.
  • Call recording is inherently incompatible with E2EE calls and the flags are mutually exclusive.

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.