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 device object must carry a valid Ed25519
self-signature. Cross-signed devices additionally require a valid master
-> self-signing -> device chain. Trusting the master remains a
user decision; an internally valid chain alone does not establish that
the master belongs to the expected person.m.room_key_request events. A
forwarded key is accepted only for an outstanding request and only over
Olm from this user’s cross-signed device. Pass the locally trusted
master to mx_crypto_known_devices() as
self_master_key; without it, this user’s devices are not
cross-signed. This cannot recover another user’s historical outbound
session, and decrypted history from a forwarded key never reports its
original sender as verified.The console procedure below uses the same Matrix verification messages as other clients. It does not require access to their private databases.
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.
Stop any other process using this device’s crypto store, then load the same persisted account and bootstrap its cross-signing identity:
keys <- mx_crypto_cross_signing_bootstrap(
client, acct, store,
password = Sys.getenv("MATRIX_PASSWORD"))
devices <- mx_crypto_known_devices(client, client$user_id, strict = TRUE,
self_master_key = keys$master)
mine <- Filter(function(device) {
identical(device$device_id, client$device_id)
}, devices)
stopifnot(length(mine) == 1L, isTRUE(mine[[1L]]$cross_signed))The password is used only when the homeserver requires password-based
UIA and must not be printed or embedded in source. A completed
auth object can be passed instead. Bootstrap saves the
private master, self-signing, and user-signing keys before uploading
public objects. It is idempotent when the local and server master keys
agree and fails closed if the server identity has no matching local
private keys. Valid device and master signatures already returned by
/keys/query are skipped on rerun.
Cross-signed is not the same as trusted. Another client must independently verify the master identity before treating its signature chain as belonging to the expected person.
Use mx.client >= 0.2.0.10 with the released mx.crypto >= 0.2.2.
Builds without the SAS primitives can still perform existing E2EE
operations, but the SAS entry points refuse with an upgrade message. The
handshake negotiates m.sas.v1,
curve25519-hkdf-sha256, sha256, and
hkdf-hmac-sha256.v2. It supports in-room and to-device
requests, emoji and decimal displays, cancellation, timeouts,
simultaneous starts, commitment checks, and both device/master MACs.
For a bot, stop the service that owns its Matrix device. Open an interactive R console on that host, load its exact existing config and crypto store, and run:
client <- mx_client_load(path = config_path)
result <- mx_verify_console(client, existing_crypto_store,
"@peer:example.org", "!room:example.org", exclusive = TRUE)config_path and existing_crypto_store are
paths supplied by the operator, not new stores. Cross-signing must
already be bootstrapped for this identity. The function refuses missing
stores or mismatched local/published keys. It does not create accounts,
reset keys, change room membership, or read a FluffyChat database. The
other user’s app can run on another computer; an SSH terminal is
sufficient for the R side. That app owns its own signing keys and may
ask its user to unlock them through its normal interface.
In the peer’s app, choose Start verification. Accept
in R, compare all 7 emoji (with labels) or all 3 numbers against the
app, and explicitly confirm in both interfaces. Empty input is
rejection. Compare in person, on your own two screens, or over a trusted
independent channel. Never put the comparison in the Matrix conversation
being verified. An LLM must not supply the confirmation. A peer that
authenticates only its device key, without its master key, is refused
for cross-user verification. For another device of this same account,
its device MAC is sufficient: this account already has a trusted local
master and signs the newly authenticated device. Some clients omit their
own master proof until that identity is locally verified. If the console
reports cancel_detail = "peer_master_missing", restore or
verify the existing identity through that client’s normal recovery
interface before retrying. Matching emoji alone do not authenticate an
omitted master key. Never accept an unproven master or reset an identity
implicitly. An owner-approved identity replacement is a separate
recovery decision, described below.
After the call returns, inspect result$status.
local_trust_recorded means this account’s signature was
confirmed by read-back. peer_done means the other client
acknowledged completion; it is not a read-back of the other user’s
private user-signing key. A cancelled or timed-out flow can still have
recorded local trust if cancellation happened after that upload. Report
that partial state, then recheck before repeating. Interrupted ephemeral
handshakes cannot be restored; start a new verification request. Each
console attempt reloads its saved config so a reused R client object
cannot rewind the cursor to an earlier attempt. Changed server, user, or
device identities are refused before network or crypto-store
operations.
Restart the bot only after the console returns.
exclusive = TRUE asserts that you stopped other consumers;
it is not a process lock. The console advances the same cursor after
saving crypto state. Ordinary messages read during the session are
passed to on_messages and returned in
result$messages. The default reports counts rather than
interleaving untrusted chat text with the verification prompts. A paused
bot will not automatically process those messages. This is a temporary
console takeover, not an always-on bot verification UI.
These steps describe the FluffyChat 2.9.1 interface with Matrix Dart SDK 10.2.0. Labels and recovery behavior may differ in other builds.
Start in the correct account and room. In a multi-account app, close any open recovery page, select the intended account, and confirm its full Matrix ID in Settings before reopening Chat backup. Keep that account selected through any authentication prompt. FluffyChat exposes Start verification in an encrypted direct chat; a room with 2 members is not necessarily marked as a direct chat. Use the existing DM with the peer. Do not create a new room or enable encryption merely to make a missing button appear.
The app displays several independent states:
| Display | What it establishes |
|---|---|
| Signed or verified device | Trust in that device’s keys, not necessarily recovery of the account’s signing keys. |
| Verified account identity | Trust in the account’s master key. Cross-user SAS authenticates this key as well as the device. |
| Known since | When the identity was first seen, not independent authentication. |
| Encrypted room | Encryption is enabled; a readable new message and reply are separate delivery checks. |
Restore Crypto Identity means required recovery secrets are unavailable locally. It can reflect a missing backup secret even when some signing keys are present. It does not prove all keys are lost. First use the existing recovery key or passphrase, or another device that still has the identity. If automatic verification does not appear, inspect Settings → Devices and start verification with the specific other device. A verified-device badge does not prevent that action. Compare and confirm on both screens. Skipping a recovery prompt may permit device-only verification; it does not recreate missing signing keys. Device names need not identify a unique host, and an old Last active timestamp alone does not prove a device is unused.
If no recovery route works, the account owner may separately authorize a new crypto identity after accepting the history and trust consequences. In the interface above, the route is Settings → Chat backup → Restore Crypto Identity → Reset account, followed by the crypto-identity reset screen. Confirm that it is the crypto identity being reset, not account deletion. This replaces the cross-signing keys, secret storage, and backup version. It preserves the current login, device, and locally held room keys; existing local room keys are queued for the new backup. History whose keys exist only in an inaccessible old backup may remain unreadable. Do not use Export session and wipe device for this procedure.
| Credential | Purpose |
|---|---|
| Matrix account login password | Authorizes publishing replacement identity keys when the server asks for password authentication. |
| Crypto recovery key or backup passphrase | Opens the account’s encrypted secret storage. Save the new recovery material securely after a reset. |
| Computer or desktop keyring password | Opens local operating-system storage; it is not the Matrix login password. |
Keep these credentials local. Do not paste them into chat or a task record. Reset only the selected account, once. Other sessions of that account should restore the new identity using the new recovery material, not reset it again. Another account in the same app must remain untouched. Existing sessions may continue reading encrypted conversations without logging in again; that does not establish that they have recovered the new signing keys.
After the peer’s identity is ready, use a fresh verification request in the R console. Load the intended package builds and retain the existing config, device, and store. Do not rewind sync or delete state after a cancellation. After matching and confirming on both screens, inspect:
result$status[c("phase", "local_trust_recorded", "peer_done")]
# Expected completion: phase "done", local_trust_recorded TRUE, peer_done TRUE.This confirms local trust read-back and the peer’s completion
acknowledgement, not independent inspection of the peer’s private
signing state. End the console takeover before restarting the bot, then
send a new normal message and confirm a readable reply. That tests live
delivery separately from SAS. Messages consumed during console
verification are in result$messages and will not
automatically replay to the bot.
mx_crypto_process_sync() returns original verification
envelopes in verification_events, separate from normalized
chat messages. Save its account/session state and cursor before any
verification transport or trust upload. Create a transaction with
mx_sas_from_request() and pass further events to
mx_sas_receive(). mx_sas_outgoing() exposes a
stable outbox; acknowledge each id only after a successful send. In
encrypted rooms retain the original event type using
event_type in the encryption helpers, and carry
m.relates_to outside the ciphertext too.
mx_sas_console(sas, receive, send, complete) supplies
the trusted human prompts around those hooks. receive must
use the application’s existing consumer; do not add a second
/sync reader. complete calls
mx_sas_record_trust() only after explicit comparison and
peer MAC checks. Keep a transaction in memory, expire stale
transactions, and cancel multiple simultaneous requests from the same
peer. SAS success does not change forwarded-key admission or make
missing historical room keys available.
With mx.client 0.2.0.10, two existing identities can verify each
other without an emoji exchange. Authenticate each full master public
key through a trusted independent channel, such as the other account’s
locally controlled console. Do not copy the key from
/keys/query and pass it back as its own proof. The Matrix
cross-signing specification defines user-to-user trust as a
user-signing signature on the other master.
The following assumes alice and bob are
existing client configs, their stores already contain the matching
private cross-signing keys, and alice_pin and
bob_pin have been authenticated independently. It does not
log in, generate identities, or recover another application’s private
keys.
# Preflight both sides before making either trust change.
a <- mx_crypto_user_trust(alice, alice_store, bob$user_id, bob_pin)
b <- mx_crypto_user_trust(bob, bob_store, alice$user_id, alice_pin)
# Each account signs separately with its own existing user-signing key.
mx_crypto_verify_user(alice, alice_store, bob$user_id, bob_pin)
mx_crypto_verify_user(bob, bob_store, alice$user_id, alice_pin)
a <- mx_crypto_user_trust(alice, alice_store, bob$user_id, bob_pin)
b <- mx_crypto_user_trust(bob, bob_store, alice$user_id, alice_pin)
stopifnot(isTRUE(a$verified), isTRUE(b$verified))The check is read-only. The verifier uploads only a missing or
invalid public signature, then verifies its read-back; it never saves
ratchet state, runs /sync, or changes the local identity.
The two uploads are not atomic: if the second fails, the first can
remain recorded. Recheck both sides and rerun with the same
independently authenticated pins. A replaced identity requires a new
explicit authentication decision, not an automatic re-pin.
If one account belongs to another client, unlock and use that client’s existing signing keys through a supported path. A password or access token alone cannot supply its private user-signing key. Do not reset its identity or send its recovery key through chat to complete this procedure.
mx_crypto_known_devices(..., self_master_key = alice_pin)
reports identity_verified only when a device’s self-signing
chain reaches a master authenticated by Alice’s pin and trust signature.
Unsigned devices remain unverified even when their account’s master is
trusted. This field does not change the existing recipient policy,
sender_verified device-binding semantics, or same-user-only
forwarded-room-key admission. No SAS transaction is completed, so an
already-open interactive verification dialog is separate.
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)
signing <- mx_crypto_cross_signing_load(store)
master_pin <- if (is.null(signing)) NULL else
mx.crypto::mxc_signing_key_public(signing$master)
# Include our own devices for history recovery, plus every encrypted sender
# in this sync for attribution. This example has one other participant.
devices <- mx_crypto_known_devices(
client, c(client$user_id, "@friend:example.org"),
self_master_key = master_pin)
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,
self_device_id = client$device_id,
devices = devices)
sessions <- out$sessions
mx_crypto_sessions_save(sessions, store)
sent <- list()
for (request in out$key_requests) {
ok <- tryCatch({
mx_crypto_send_key_requests(client, list(request)); TRUE
}, error = function(e) FALSE)
if (ok) sent[[length(sent) + 1L]] <- request
}
sessions <- mx_crypto_mark_key_requests_sent(sessions, sent)
mx_crypto_sessions_save(sessions, store)
for (cancel in out$key_request_cancellations) {
try(mx_crypto_send_key_requests(client, list(cancel)), silent = TRUE)
}
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 events
become inbound Megolm sessions. Requested
m.forwarded_room_key events are imported only after their
request, sender, cross-signing chain, and computed session ID
validate.m.room.encrypted event whose session is known. An unknown
session creates one persistent request to the current user’s other
devices. Decrypted records match mx_extract_text_events()
and add sender_verified.Olm sessions carry messages in both directions. For each sender key,
the receiver tries the stored remotely initiated session
(olm_in) and the locally initiated session
(olm). This applies to both normal and prekey messages. If
neither session decrypts, only a prekey can create a new inbound
session. Messages that cannot be decrypted or used to create a session
warn and are skipped, allowing the rest of the batch to proceed. The
two-map storage format is unchanged and retains only one session per
sender key in each map.
The lower-level mx_crypto_handle_to_device() accepts a
list of existing handles through olm_sessions. It advances
a successful handle in place, but does not retain a newly created
session for the caller. Stateful clients should use
mx_crypto_process_sync() and save the returned sessions.
Undecryptable messages return NULL with a warning.
The caller saves returned crypto state before making any request
transport call. Successfully sent requests are then marked and saved
again. Failed requests remain queued with the same stable id and are
returned again on later polls; cancellation failures are safe to drop.
This ordering prevents a network error from replaying a sync batch
against already-advanced Olm and Megolm ratchets. The
chat.api Matrix adapter performs this sequence
automatically and loads its own master pin from the local crypto store
for device queries. Sent requests with no answer currently remain stored
until their key arrives; automatic expiry/pruning is deferred.
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/Megolm sessions and outstanding key requests |
cross-signing.json |
encrypted master, self-signing, and user-signing private keys |
From mx.client 0.2.0.11, sessions.json declares schema
version 1; cross-signing files already require it. Unknown or malformed
versions are rejected before unpickling, with the full file path in the
error. This is the mx.client file format version, not the mx.crypto
package version. Unversioned session stores remain readable and migrate
on save, never on load.
account.pickle continues to be written as a raw
encrypted pickle so older clients can still read it. The account loader
also accepts version-1 JSON envelopes and refuses unknown versions
without rewriting the file. Envelope writes are deferred until consumers
have upgraded; reading one and later saving the account writes the
compatible raw form.
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.