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.

The console procedure below uses the same Matrix verification messages as other clients. It does not require access to their private databases.

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.

Cross-signing bootstrap

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.

Interactive SAS verification

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.

Peer identity recovery in FluffyChat

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.

Integrating an existing event loop

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.

Mutual verification from an R console

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.

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)

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:

  1. To-device events: Olm-decrypts anything addressed to this device’s Curve25519 key. Direct 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.
  2. Room timelines: decrypts every 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.

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

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.