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.
Read this first; it frames what the rest of the vignette delivers.
/keys/query and are used as-is. There is no cross-signing
trust store yet, so a malicious homeserver could substitute device keys
on first contact. (mx.crypto ships the verification
primitives, mxc_verify_device_keys(); wiring a trust store
is future work.)m.room_key cannot ask peers to re-share
it.This matches what bots and controlled deployments need. For
human-grade verification flows, watch the mx.crypto
roadmap.
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.
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.
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_idOn the wire, mx_send_encrypted():
/keys/query — discovers the members’ devices and
identity keys (mx_crypto_known_devices())./keys/claim — claims a one-time key for each device we
have no Olm session with (mx_crypto_claim_otks()), then
opens the sessions./sendToDevice — Olm-encrypts the room’s Megolm session
key to each device that hasn’t received it (m.room_key
inside m.room.encrypted)./send — Megolm-encrypts the actual content and posts
the m.room.encrypted room event.Steps 1–3 only do work the first time. Later sends to the same room are a single Megolm encrypt + send.
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:
m.room_key
becomes an inbound Megolm session, stored under
room_id|session_id.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.
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.
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.