End-to-end encryption

End-to-end encryption

How to send and receive encrypted Matrix messages from R, and what each step does on the wire. Code blocks here are display-only: the flow needs a homeserver, a second device, and the mx.crypto package (which needs a Rust toolchain to build).

mx.crypto provides the cryptographic primitives; mx.client keeps it optional (Suggests) and only calls it from E2EE entry points, so plaintext Matrix clients install and run without Rust.

Security model

Read this first; it frames what the rest of the vignette delivers.

This matches what bots and controlled deployments need. For human-grade verification flows, watch the mx.crypto roadmap.

The pieces

Matrix encryption uses two ratchets, both provided by mx.crypto (wrapping Matrix.org’s vodozemac):

So sending one encrypted room message means: have an Olm session with each recipient device, send each of them your Megolm session key over Olm (as to-device messages), then encrypt the actual message with Megolm. mx.client orchestrates all of that; after one-time setup, a send is one call and a receive is one call.

Setup: a device with published keys

A Matrix device (a login session) gets long-lived identity keys plus a pool of one-time keys others use to open Olm sessions with it. The crypto store persists all of it (pickled with a locally stored 32-byte key, mode 0600) so the device identity survives restarts.

library(mx.client)

client <- mx_client_load(app = "myapp")    # see mx_client_configure()

store <- mx_crypto_store_dir("myapp")      # under tools::R_user_dir()
acct <- mx_crypto_account(store)           # load or mint identity keys
mx_crypto_publish_keys(client, acct, store, n_otks = 50L)

mx_crypto_publish_keys() builds the signed device_keys object, signs and uploads one-time keys (/keys/upload), marks them published, and saves the account. Run it again whenever the server’s one_time_key_counts runs low.

Sending

sessions <- mx_crypto_sessions_load(store)

res <- mx_send_encrypted(
    client, acct, sessions,
    room_id = "!abc:example.org",
    content = list(msgtype = "m.text", body = "the eagle lands at noon"),
    store_dir = store,
    member_ids = c("@friend:example.org")
)
res$event_id

On the wire, mx_send_encrypted():

  1. /keys/query — discovers the members’ devices and identity keys (mx_crypto_known_devices()).
  2. /keys/claim — claims a one-time key for each device we have no Olm session with (mx_crypto_claim_otks()), then opens the sessions.
  3. /sendToDevice — Olm-encrypts the room’s Megolm session key to each device that hasn’t received it (m.room_key inside m.room.encrypted).
  4. /send — Megolm-encrypts the actual content and posts the m.room.encrypted room event.
  5. Persists the updated sessions to the store.

Steps 1–3 only do work the first time. Later sends to the same room are a single Megolm encrypt + send.

Receiving

res <- mx_sync_update(client, timeout = 30000L)

my_curve <- mx.crypto::mxc_account_identity_keys(acct)$curve25519
out <- mx_crypto_process_sync(acct, sessions, res$sync, my_curve,
                              self_id = client$user_id)
sessions <- out$sessions
mx_crypto_sessions_save(sessions, store)

for (ev in out$events) cat(ev$sender, ":", ev$body, "\n")

mx_crypto_process_sync() makes two passes over the sync response:

  1. To-device events: Olm-decrypts anything addressed to this device’s Curve25519 key. Each recovered m.room_key becomes an inbound Megolm session, stored under room_id|session_id.
  2. Room timelines: decrypts every m.room.encrypted event whose session is known, returning records in the same shape as mx_extract_text_events()room_id, event_id, sender, is_self, body, msgtype, mentions.

Events whose keys haven’t arrived yet are skipped, not errored; they decrypt on a later pass once the to-device message lands.

Persistence

Everything stateful lives in the crypto store directory:

File Holds
pickle.key the locally stored 32-byte key the pickles are encrypted with
account.pickle device identity (Curve25519 + Ed25519 keys, OTK state)
sessions.json pickled Olm sessions and Megolm in/outbound sessions

mx_crypto_sessions_save() / mx_crypto_sessions_load() round-trip the session set, so an established room key keeps decrypting across process restarts. One caution: the account binds to the config’s device_id. A homeserver will reject re-publishing different identity keys for an existing device, so don’t delete the store while keeping the login.

Marking a room encrypted

Encryption is a room state event. Any member with permission can set it (this is one-way; rooms don’t downgrade to plaintext):

s <- mx_client_session(client)
mx.api::mx_set_state(s, room_id, "m.room.encryption",
                     list(algorithm = "m.megolm.v1.aes-sha2"))

The boundaries of what this layer does are in the Security model section at the top.