diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 8bb75b3..19a45fa 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -27,5 +27,68 @@ jobs:
- name: Dependencies
run: ./run.sh install_deps
+ # test_matrix_mxclient.R exit_file()s without mx.client, and that
+ # file holds the direct tests for the crypto boundary functions --
+ # the encryption-state lookup and the encrypted send, both of which
+ # leak if they fail open. They sit behind chat_matrix()'s .crypto
+ # seam, so nothing in test_matrix.R can reach them. install_deps
+ # takes hard dependencies only, so without this they skip
+ # themselves into a green check.
+ #
+ # curl and jsonlite come first and explicitly: chat.api has no
+ # dependencies of its own, so install_deps brings in nothing, and
+ # mx.api needs both.
+ - name: Install Matrix stack
+ run: |
+ Rscript -e 'install.packages(c("curl", "jsonlite"))'
+ for pkg in mx.api mx.client; do
+ git clone --depth 1 "https://github.com/cornball-ai/$pkg.git" "$RUNNER_TEMP/$pkg"
+ R CMD INSTALL "$RUNNER_TEMP/$pkg"
+ done
+
+ # mx.crypto needs a Rust toolchain to build from source, so it is
+ # Linux-only here, off the r2u binary rapt already has sources for.
+ # Its one test -- that matrix_crypto_init() binds the store before
+ # opening the account -- is conditional and does not run on macOS.
+ - name: Install mx.crypto (Linux)
+ if: runner.os == 'Linux'
+ run: Rscript -e 'install.packages("mx.crypto")'
+
+ # Fail loudly rather than letting the suite skip. mx.crypto is
+ # reported but not required, since the macOS leg does without it.
+ #
+ # The mx.client floor is enforced, not just declared: the adapter
+ # calls straight into mx_send_encrypted() and cannot work around
+ # what an older one gets wrong. Every step of reading that floor is
+ # checked, because a parse that quietly finds nothing would wave
+ # through exactly what this is here to catch -- which is what the
+ # first version of corteza's equivalent did on macOS.
+ - name: Verify Matrix stack
+ run: |
+ Rscript -e '
+ for (p in c("mx.api", "mx.client")) {
+ if (!requireNamespace(p, quietly = TRUE))
+ stop("chat.api CI needs ", p, ": without it ",
+ "test_matrix_mxclient.R skips itself", call. = FALSE)
+ }
+ sug <- read.dcf("DESCRIPTION")[1L, "Suggests"]
+ if (is.na(sug)) stop("no Suggests field in DESCRIPTION")
+ e <- grep("^mx[.]client",
+ trimws(strsplit(sug, ",")[[1L]]), value = TRUE)
+ if (!length(e)) stop("no mx.client entry in Suggests")
+ m <- regmatches(e, regexpr("[0-9][0-9.-]*", e))
+ if (!length(m)) stop("mx.client Suggests entry declares no floor")
+ floor <- package_version(m)
+ have <- utils::packageVersion("mx.client")
+ message("mx.client ", have, " (>= ", floor, ")")
+ if (have < floor)
+ stop("mx.client ", have, " is below the ", floor,
+ " chat.api declares", call. = FALSE)
+ message("mx.api ", utils::packageVersion("mx.api"))
+ message("mx.crypto ",
+ if (requireNamespace("mx.crypto", quietly = TRUE))
+ as.character(utils::packageVersion("mx.crypto"))
+ else "absent (crypto-init test will not run)")'
+
- name: Test
run: ./run.sh run_tests
diff --git a/DESCRIPTION b/DESCRIPTION
index 489823f..419cecf 100644
--- a/DESCRIPTION
+++ b/DESCRIPTION
@@ -1,8 +1,8 @@
Package: chat.api
Type: Package
Title: Transport-Agnostic Chat Contract for R Agents
-Version: 0.0.1.2
-Date: 2026-07-20
+Version: 0.0.1.11
+Date: 2026-08-05
Authors@R: c(
person("Troy", "Hernandez", role = c("aut", "cre"),
email = "troy@cornball.ai",
@@ -21,7 +21,7 @@ URL: https://github.com/cornball-ai/chat.api
BugReports: https://github.com/cornball-ai/chat.api/issues
Suggests:
mx.api,
- mx.client (>= 0.1.1.2),
+ mx.client (>= 0.2.0.2),
mx.crypto,
slackr,
tinytest
diff --git a/R/contract.R b/R/contract.R
index 80a40e2..afb34b2 100644
--- a/R/contract.R
+++ b/R/contract.R
@@ -151,17 +151,30 @@ chat_disconnect.default <- function(client, ...) {
#' adapter-specific and may already be normalized by the transport
#' package (Matrix hands over an extracted record, not the timeline
#' event), so it is an escape hatch, not a guarantee of completeness.
+#' @param encrypted Logical: did this message arrive end-to-end
+#' encrypted? FALSE on transports without E2EE and on cleartext
+#' messages in rooms that have it.
+#' @param sender_verified Logical: does the sender identifier bind to a
+#' device whose keys this client verified? NULL on cleartext messages,
+#' where the transport asserts the sender and there is nothing to
+#' verify. On an encrypted message FALSE is a real answer, not a
+#' missing one: the payload decrypted, but its claimed sender could not
+#' be tied to a verified device, so the identifier is the homeserver's
+#' word rather than cryptographic fact.
#' @return A list with class \code{chat_message}.
#' @export
chat_message <- function(id, channel, sender, body, ts, thread = NULL,
markup = "plain", kind = "message", self = NULL,
- mentions = NULL, raw = NULL) {
+ mentions = NULL, raw = NULL, encrypted = FALSE,
+ sender_verified = NULL) {
stopifnot(is.character(id), is.character(channel), is.character(sender),
is.character(body))
structure(list(id = id, channel = channel, sender = sender,
body = body, ts = ts, thread = thread,
markup = markup, kind = kind, self = self,
- mentions = mentions, raw = raw),
+ mentions = mentions, raw = raw,
+ encrypted = isTRUE(encrypted),
+ sender_verified = sender_verified),
class = "chat_message")
}
diff --git a/R/matrix-crypto.R b/R/matrix-crypto.R
new file mode 100644
index 0000000..8ba5b83
--- /dev/null
+++ b/R/matrix-crypto.R
@@ -0,0 +1,604 @@
+# Matrix end-to-end encryption for the chat.api adapter.
+#
+# Olm/Megolm state owned by the adapter, so a consumer never handles
+# crypto itself. All cryptography is delegated to mx.client and
+# mx.crypto, both Suggests, both behind requireNamespace.
+#
+# Ported from corteza, which carried this in parallel to the contract and
+# reached around chat_poll()$raw to do it. None of it is exported: the
+# whole point is that E2EE is a property of the client a consumer already
+# holds, not a second API it has to learn.
+
+matrix_crypto_available <- function() {
+ requireNamespace("mx.client", quietly = TRUE) &&
+ requireNamespace("mx.crypto", quietly = TRUE)
+}
+
+# The Matrix device an Olm account belongs to: (user_id, device_id).
+#
+# Both are required. An Olm account is a device's identity, not a user's,
+# so a store keyed on anything coarser is a store two identities can end
+# up sharing -- and the second to open it comes up wearing the first's
+# device keys.
+matrix_crypto_identity <- function(mx) {
+ uid <- mx$user_id %||% ""
+ did <- mx$device_id %||% ""
+ if (!nzchar(uid) || !nzchar(did)) {
+ stop("chat.api: end-to-end encryption needs both user_id and ",
+ "device_id on the Matrix config. An Olm account belongs to a ",
+ "device, and a store that cannot name one is a store two ",
+ "identities can share.", call. = FALSE)
+ }
+ c(user_id = uid, device_id = did)
+}
+
+# Where this identity's Olm account and sessions live.
+#
+# Under mx.client's own convention, R_user_dir(app, "data")/crypto, with a
+# per-device subdirectory. Deliberately NOT derived from the config file's
+# location: corteza computed dirname(config)/crypto, which ties the
+# identity to wherever the config happens to sit, so a moved config
+# silently minted a new device and lost every Megolm session it had.
+#
+# The directory name is sanitized and therefore not injective -- "@a/b:ex"
+# and "@a_b:ex" land in the same place. That is why the exact identity is
+# written into the store and checked on every open: a collision has to
+# fail loudly rather than hand one bot another's account.
+matrix_crypto_store <- function(mx, app = NULL) {
+ id <- matrix_crypto_identity(mx)
+ slug <- gsub("[^A-Za-z0-9._-]", "_",
+ paste(id[["user_id"]], id[["device_id"]], sep = "|"))
+ file.path(mx.client::mx_crypto_store_dir(app = app %||% "chat.api"), slug)
+}
+
+# Bind a store to one device identity, and refuse to open one bound to
+# another. Written on first use; compared exactly thereafter, so a
+# sanitization collision, a copied store directory, or a device_id change
+# is an error instead of a silent account swap.
+matrix_crypto_bind_identity <- function(store, mx) {
+ id <- matrix_crypto_identity(mx)
+ f <- file.path(store, "identity.txt")
+ if (file.exists(f)) {
+ have <- readLines(f, warn = FALSE)
+ want <- c(id[["user_id"]], id[["device_id"]])
+ if (!identical(have[seq_along(want)], want)) {
+ stop("chat.api: crypto store ", store, " belongs to ",
+ paste(have, collapse = "/"), ", not ",
+ paste(want, collapse = "/"),
+ ". Refusing to open another device's Olm account.",
+ call. = FALSE)
+ }
+ return(invisible(store))
+ }
+ dir.create(store, showWarnings = FALSE, recursive = TRUE)
+ writeLines(c(id[["user_id"]], id[["device_id"]]), f)
+ invisible(store)
+}
+
+# Crypto contexts already built in this session, keyed per identity.
+#
+# One identity is one Olm account, and building a second client for the
+# same identity must not mint a second context: the account pickle, its
+# published one-time keys, and the Megolm session set are all per-device
+# state, and two contexts writing one store would overwrite each other's
+# pickles.
+#
+# This is what lets a consumer rebuild its client as often as it likes.
+# corteza builds one per use on purpose -- the access token rotates
+# mid-loop, so anything holding a config from before a relogin keeps
+# authenticating with the rejected one -- and interning means that costs
+# an environment lookup rather than an account load and a 50-key upload.
+.crypto_cache <- new.env(parent = emptyenv())
+
+# The cache key is the device identity and nothing else.
+#
+# Matrix gives a device one long-lived ed25519 and one curve25519 key,
+# for the life of that device_id. So the key cannot include the store:
+# with the store in it, two stores for one device_id minted two Olm
+# accounts and therefore two identity keys for a device the spec says has
+# one -- and two spellings of the same directory produced two mutable
+# contexts over one set of pickles, each overwriting the other's Megolm
+# sessions.
+#
+# identity.txt already enforces store -> identity. This is the other
+# direction, identity -> store, which that file cannot see.
+matrix_crypto_cache_key <- function(mx) {
+ id <- matrix_crypto_identity(mx)
+ paste(id[["user_id"]], id[["device_id"]], sep = "\n")
+}
+
+# A store request, normalized so two spellings of one directory are one
+# request. NULL means "derive the default", which is itself a stable
+# request: two clients that both defer resolve the same way.
+matrix_crypto_store_spec <- function(store = NULL, app = NULL) {
+ if (is.null(store)) {
+ return(paste0("app:", app %||% "chat.api"))
+ }
+ matrix_normalize_path(store)
+}
+
+# Lexical path normalization: expand ~, make it absolute, fold separators,
+# and resolve . and .. by hand.
+#
+# Deliberately not normalizePath(). That resolves symlinks, which this
+# does not, but it returns a path that does not exist yet exactly as
+# given -- and a store's first use is exactly when it does not exist. Two
+# calls for one directory would then disagree depending on whether it had
+# been created between them, which is worse than not resolving symlinks
+# at all: the comparison this feeds has to be stable over the store's
+# whole life, not accurate at one moment of it.
+#
+# The trailing-separator strip this replaces also ate the slash off a
+# Windows drive root, turning "C:/" into "C:".
+matrix_normalize_path <- function(p) {
+ p <- gsub("\\\\", "/", path.expand(p))
+ # "C:store" is drive-relative: Windows resolves it against the current
+ # directory *on drive C*, which R cannot read and this cannot fold.
+ # Treating "C:" as a root made "C:../x", "C:x" and "C:a/../../x" one
+ # spec when they are three different directories, so two stores could
+ # still share a context. Keeping unresolved leading ".." would make
+ # the spec honest and still not canonical -- two spellings of one
+ # directory would stay two. There is no correct answer available, so
+ # this asks for one that is.
+ if (grepl("^[A-Za-z]:([^/]|$)", p)) {
+ stop("chat.api: crypto_store cannot be a Windows drive-relative ",
+ "path (", p, "). It names a directory that depends on the ",
+ "current directory of that drive, which cannot be resolved ",
+ "here, so two such paths cannot be told apart. Give an ",
+ "absolute path, like C:/store.", call. = FALSE)
+ }
+ r <- matrix_path_root(p)
+ if (!nzchar(r[["root"]])) {
+ # Relative to wherever the caller stood. Two callers in different
+ # directories mean two stores, and saying so beats merging them.
+ return(matrix_normalize_path(file.path(gsub("\\\\", "/", getwd()), p)))
+ }
+ out <- character()
+ for (part in strsplit(r[["tail"]], "/")[[1L]]) {
+ if (!nzchar(part) || identical(part, ".")) {
+ next
+ }
+ if (identical(part, "..")) {
+ if (length(out)) {
+ out <- out[-length(out)]
+ }
+ # Above the root there is nothing to go up to; drop it.
+ next
+ }
+ out <- c(out, part)
+ }
+ paste0(r[["root"]], paste(out, collapse = "/"))
+}
+
+# Split a path into the root it is anchored to and the rest.
+#
+# The root is not always "/". Collapsing every leading slash run into one
+# made //server/share/x, \\server\share\x and /server/share/x the same
+# spec, and on Windows the first two are a UNC share while the third is a
+# local path -- so a request naming one store would have been handed the
+# context of another, which is the collision the whole check exists to
+# stop. Three or more leading slashes are POSIX, not UNC, and collapse.
+#
+# Drive-relative paths never reach here: matrix_normalize_path() rejects
+# them, because "C:" is a drive and not a root to fold components against.
+matrix_path_root <- function(p) {
+ if (grepl("^//[^/]", p)) {
+ m <- regmatches(p, regexpr("^//[^/]+(/[^/]+)?", p))
+ return(c(root = paste0(m, "/"), tail = substring(p, nchar(m) + 1L)))
+ }
+ if (grepl("^[A-Za-z]:/", p)) {
+ return(c(root = substr(p, 1L, 3L), tail = substring(p, 4L)))
+ }
+ if (startsWith(p, "/")) {
+ return(c(root = "/", tail = substring(p, 2L)))
+ }
+ c(root = "", tail = p)
+}
+
+# One identity, one context, one store. A second store for a device
+# already holding one is refused rather than silently ignored or silently
+# honoured: re-homing a device's Olm account is a re-provision, and a
+# re-provisioned device gets a new device_id. matrix_crypto_forget() is
+# the way out for anything that really means to start over.
+matrix_crypto_context <- function(key, spec, factory) {
+ prev <- .crypto_cache[[key]]
+ if (!is.null(prev)) {
+ if (!identical(prev$spec, spec)) {
+ stop("chat.api: this Matrix device already has a crypto store ",
+ "at ", prev$spec, "; refusing a second at ", spec,
+ ". A device has one Olm identity, so two stores would be ",
+ "two identity keys for one device_id.", call. = FALSE)
+ }
+ return(prev$ctx)
+ }
+ ctx <- factory()
+ .crypto_cache[[key]] <- list(ctx = ctx, spec = spec)
+ ctx
+}
+
+# The client's crypto context, built on first use.
+#
+# Deliberately not built in chat_matrix(). Initialization publishes keys,
+# which is an authenticated request, and the token on disk may already be
+# rejected at process start -- that is the case mx_with_relogin() exists
+# for. Building at construction put the upload before any relogin could
+# happen, so chat_matrix() threw, corteza's poll loop never got to run,
+# and every restart repeated with the same dead token.
+#
+# What waiting buys depends on which call gets here first. From
+# chat_poll() the sync has already run inside mx_with_relogin(), so the
+# keys go up on a token that just worked. From a chat_send() that
+# precedes any poll they go up on whatever token the config holds --
+# nothing wraps a send, so that is the same exposure any direct send
+# already has, and it is still strictly later than the constructor.
+#
+# The result is stashed on the client's env, which is shared with every
+# copy of the client, so this costs one lookup after the first call.
+matrix_crypto_require <- function(client) {
+ if (!isTRUE(client$e2ee)) {
+ return(NULL)
+ }
+ if (!is.null(client$env$crypto)) {
+ return(client$env$crypto)
+ }
+ mx <- client$env$mx
+ ctx <- matrix_crypto_context(
+ matrix_crypto_cache_key(mx),
+ matrix_crypto_store_spec(client$crypto_store, client$app),
+ function() {
+ client$crypto_ops$init(mx, store = client$crypto_store,
+ app = client$app)
+ })
+ client$env$crypto <- ctx
+ ctx
+}
+
+# Drop interned contexts. Tests need it because they build many clients
+# per identity on purpose; nothing in production calls it, since forgetting
+# a context loses the Megolm sessions decrypted since the last save.
+matrix_crypto_forget <- function(key = NULL) {
+ if (is.null(key)) {
+ rm(list = ls(.crypto_cache), envir = .crypto_cache)
+ } else if (!is.null(.crypto_cache[[key]])) {
+ rm(list = key, envir = .crypto_cache)
+ }
+ invisible(NULL)
+}
+
+# The crypto operations the adapter calls, as a replaceable set. Same
+# reason chat_matrix() takes .sync and .extract: the Matrix methods have
+# to be verifiable on a runner with neither mx.client nor a Rust
+# toolchain, and E2EE is the one part of the adapter that cannot be
+# exercised against a real homeserver in a test.
+matrix_crypto_ops <- function(override = NULL) {
+ ops <- list(init = matrix_crypto_init,
+ encrypted = matrix_room_is_encrypted,
+ send = matrix_crypto_send, decrypt = matrix_crypto_decrypt)
+ for (nm in names(override)) {
+ ops[[nm]] <- override[[nm]]
+ }
+ ops
+}
+
+# Does this account hold the keys the homeserver already has for this
+# device? Matrix gives a device_id one ed25519 and one curve25519 for its
+# whole life, and the homeserver is the authority on which.
+#
+# The in-process cache cannot answer this. It stops two contexts existing
+# at once, and nothing more: restart the process, or call
+# matrix_crypto_forget(), and a changed crypto_store mints a fresh
+# account that would go on to publish different long-lived keys under the
+# old device_id. So "re-homing needs a new device_id" was commentary. This
+# is the enforcement, and it is durable because the record it checks
+# against lives on the server rather than in this session.
+#
+# Three outcomes, and they have to stay three:
+#
+# absent -- first run, proceed and publish
+# present, valid -- compare against this account
+# present, invalid -- abort
+#
+# The raw /keys/query response is read rather than
+# mx_crypto_known_devices(), which verifies signatures and drops what
+# fails with a warning. That is right for choosing who to encrypt to and
+# wrong here: a tampered or signature-stripped entry for our own device
+# would come back as no entry, collapse "present but unverifiable" into
+# "absent", and publish over it. Another failure read as absence.
+#
+# What this cannot see is a homeserver that omits the device on purpose.
+# That is indistinguishable from a first run over this channel alone, and
+# closing it needs key pinning or cross-signing -- tracked separately.
+#
+# A query that cannot be answered is an error: init is about to publish
+# keys to that same homeserver, so it is not a host we can be off-line
+# from, and the caller retries on the next poll.
+matrix_crypto_check_published <- function(mx, acct) {
+ mine <- mx.crypto::mxc_account_identity_keys(acct)
+ uid <- mx$user_id
+ did <- mx$device_id
+ resp <- tryCatch({
+ s <- mx.client::mx_client_session(mx)
+ mx.api::mx_keys_query(s,
+ device_keys = stats::setNames(list(list()), uid))
+ }, error = function(e) {
+ stop("chat.api: cannot read this device's published keys from the ",
+ "homeserver (", conditionMessage(e), "). Refusing to publish ",
+ "an Olm identity that may conflict with one already there.",
+ call. = FALSE)
+ })
+ # /keys/query answers 200 with a `failures` map when it could not
+ # reach a server, and the device_keys it returns are whatever it did
+ # manage. This query names one user -- ours -- so any failure at all
+ # means our question went unanswered, and the empty result that comes
+ # back with it is not evidence of a device that was never published.
+ # A request that throws and a request that succeeds with nothing in it
+ # are the same state to a caller about to publish keys.
+ if (length(resp$failures)) {
+ stop("chat.api: the homeserver could not answer for ",
+ paste(names(resp$failures), collapse = ", "),
+ " when asked about this device's published keys. Refusing to ",
+ "publish an Olm identity against an unanswered question.",
+ call. = FALSE)
+ }
+ entry <- (resp$device_keys %||% list())[[uid]][[did]]
+ if (is.null(entry)) {
+ return(invisible(TRUE))
+ }
+ keys <- tryCatch(mx.crypto::mxc_verify_device_keys(entry, uid, did),
+ error = function(e) {
+ stop("chat.api: the homeserver has an entry for ", uid, "/", did,
+ " whose device keys do not verify (", conditionMessage(e),
+ "). Refusing to publish over a device record that cannot be ",
+ "checked -- an unverifiable entry is not an absent one.",
+ call. = FALSE)
+ })
+ if (identical(keys$ed25519, mine$ed25519) &&
+ identical(keys$curve25519, mine$curve25519)) {
+ return(invisible(TRUE))
+ }
+ stop("chat.api: the homeserver already has different device keys for ",
+ uid, "/", did, " (ed25519 ", substr(keys$ed25519 %||% "", 1L, 12L),
+ "..., this store has ", substr(mine$ed25519 %||% "", 1L, 12L),
+ "...). This store holds another device's Olm account, or the one ",
+ "this device used has been lost. A device has one identity for its ",
+ "lifetime, so log in again for a new device_id rather than ",
+ "republishing under this one.", call. = FALSE)
+}
+
+# Load or create the account, publish keys, restore sessions and the
+# known-encrypted-room set. Returns an environment: the poll loop mutates
+# sessions in place, and callers hold the same object across polls.
+#
+# The store directory is derived here rather than by the caller so that
+# mx.client is only needed on the path that actually does crypto: a
+# .crypto seam replaces this whole function, and a test using one must
+# not pay for mx_crypto_store_dir().
+matrix_crypto_init <- function(mx, store = NULL, app = NULL) {
+ if (!matrix_crypto_available()) {
+ stop("Matrix end-to-end encryption requires the 'mx.client' and ",
+ "'mx.crypto' packages.", call. = FALSE)
+ }
+ store <- store %||% matrix_crypto_store(mx, app = app)
+ matrix_crypto_bind_identity(store, mx)
+ acct <- mx.client::mx_crypto_account(store)
+ matrix_crypto_check_published(mx, acct)
+ mx.client::mx_crypto_publish_keys(mx, acct, store, n_otks = 50L)
+ crypto <- new.env(parent = emptyenv())
+ crypto$account <- acct
+ crypto$store <- store
+ # No cached client. The access token rotates -- chat_poll() relogins --
+ # and a copy taken here goes stale the moment it does. Every send and
+ # decrypt takes the adapter's live env$mx.
+ crypto$sessions <- mx.client::mx_crypto_sessions_load(store)
+ crypto$encrypted <- matrix_crypto_load_encrypted(store)
+ crypto$self_curve <-
+ mx.crypto::mxc_account_identity_keys(acct)$curve25519
+ # Ask each joined room for its encryption state up front rather than
+ # waiting for a sync to mention it. Best-effort per room; a transient
+ # failure defers that room to sync-time detection.
+ found <- matrix_crypto_scan_rooms(mx)
+ fresh <- setdiff(found, crypto$encrypted)
+ if (length(fresh)) {
+ crypto$encrypted <- c(crypto$encrypted, fresh)
+ matrix_crypto_save_encrypted(crypto)
+ }
+ crypto
+}
+
+# Joined rooms advertising m.room.encryption, by direct state query.
+matrix_crypto_scan_rooms <- function(mx) {
+ s <- tryCatch(mx.client::mx_client_session(mx), error = function(e) NULL)
+ if (is.null(s)) {
+ return(character())
+ }
+ rooms <- tryCatch(mx.api::mx_rooms(s), error = function(e) character())
+ out <- character()
+ for (rid in rooms) {
+ if (isTRUE(tryCatch(mx.client::mx_room_encrypted(mx, rid),
+ error = function(e) FALSE))) {
+ out <- c(out, rid)
+ }
+ }
+ out
+}
+
+# One room id per line, not JSON: chat.api has no dependencies and a
+# character vector does not need a parser. corteza wrote
+# encrypted_rooms.json here; the name differs so a leftover file is
+# ignored rather than read as room ids. Nothing is lost by ignoring it --
+# the room set is a cache, and matrix_crypto_scan_rooms() rebuilds it at
+# init from the homeserver's own state.
+matrix_crypto_room_file <- function(store) {
+ file.path(store, "encrypted_rooms.txt")
+}
+
+matrix_crypto_load_encrypted <- function(store) {
+ f <- matrix_crypto_room_file(store)
+ if (!file.exists(f)) {
+ return(character())
+ }
+ rooms <- readLines(f, warn = FALSE)
+ unique(rooms[nzchar(rooms)])
+}
+
+matrix_crypto_save_encrypted <- function(crypto) {
+ dir.create(crypto$store, showWarnings = FALSE, recursive = TRUE)
+ writeLines(crypto$encrypted, matrix_crypto_room_file(crypto$store))
+}
+
+# Rooms advertising m.room.encryption in this sync (state or timeline).
+matrix_detect_encrypted_rooms <- function(sync) {
+ out <- character()
+ joined <- sync$rooms$join %||% list()
+ for (rid in names(joined)) {
+ evs <- c(joined[[rid]]$state$events %||% list(),
+ joined[[rid]]$timeline$events %||% list())
+ for (ev in evs) {
+ if (isTRUE(ev$type == "m.room.encryption")) {
+ out <- c(out, rid)
+ break
+ }
+ }
+ }
+ out
+}
+
+# Decrypt a sync: recover room keys from to-device, decrypt encrypted
+# timeline events, refresh the encrypted-room set. Mutates `crypto`.
+#
+# `devices` is the verified device list for the senders in this sync.
+# mx.client only reports sender_verified = TRUE when a payload's claimed
+# (sender, ed25519, curve25519) binds to a device whose device_keys
+# verified, so without this every decrypted message would come back
+# unattributed. Fetching it is a /keys/query round trip, so it is skipped
+# when the sync carries no encrypted traffic.
+matrix_crypto_decrypt <- function(crypto, sync, mx) {
+ fresh <- setdiff(matrix_detect_encrypted_rooms(sync), crypto$encrypted)
+ if (length(fresh)) {
+ crypto$encrypted <- c(crypto$encrypted, fresh)
+ matrix_crypto_save_encrypted(crypto)
+ }
+ senders <- matrix_encrypted_senders(sync)
+ devices <- if (length(senders)) {
+ tryCatch(mx.client::mx_crypto_known_devices(mx, senders),
+ error = function(e) NULL)
+ } else {
+ NULL
+ }
+
+ res <- mx.client::mx_crypto_process_sync(
+ crypto$account, crypto$sessions, sync, crypto$self_curve,
+ self_id = mx$user_id, devices = devices)
+ crypto$sessions <- res$sessions
+ mx.client::mx_crypto_sessions_save(crypto$sessions, crypto$store)
+ res$events
+}
+
+# Distinct senders the device query has to cover, so it asks about the
+# people who actually spoke rather than every room member.
+#
+# Both halves matter, and the to-device half is the one that is easy to
+# miss. sender_bound is stamped once, when a room key arrives over
+# to-device, and it is persisted with the Megolm session; a later sync
+# that finally carries a timeline message from that sender does not rebind
+# it. A room key that arrives in a to-device-only sync -- which is the
+# normal case, since the key is shared before the message -- would
+# therefore be recorded unverified forever, and every message decrypted
+# with it would report sender_verified = FALSE.
+matrix_encrypted_senders <- function(sync) {
+ out <- character()
+ for (room in sync$rooms$join %||% list()) {
+ for (ev in room$timeline$events %||% list()) {
+ if (isTRUE(ev$type == "m.room.encrypted") && !is.null(ev$sender)) {
+ out <- c(out, ev$sender)
+ }
+ }
+ }
+ for (ev in sync$to_device$events %||% list()) {
+ if (isTRUE(ev$type == "m.room.encrypted") && !is.null(ev$sender)) {
+ out <- c(out, ev$sender)
+ }
+ }
+ unique(out)
+}
+
+# Is this room one the adapter knows to be encrypted?
+#
+# Fails closed. A room not already known encrypted is asked about, so one
+# that turned encryption on between polls does not get a cleartext send --
+# and if that question cannot be answered, the caller gets an error rather
+# than FALSE. An expired token, a timeout, or a 500 all used to come back
+# as "not encrypted", which sent the message in the clear: on an E2EE
+# client, unknown is not the same answer as unencrypted.
+matrix_room_is_encrypted <- function(crypto, mx, room_id) {
+ if (is.null(crypto)) {
+ return(FALSE)
+ }
+ if (room_id %in% crypto$encrypted) {
+ return(TRUE)
+ }
+ enc <- withCallingHandlers(
+ tryCatch(mx.client::mx_room_encrypted(mx, room_id),
+ error = function(e) {
+ stop("chat.api: cannot determine the encryption state of ", room_id,
+ ": ", conditionMessage(e),
+ ". Refusing to send, because an unanswered question is ",
+ "not a plaintext room.", call. = FALSE)
+ }), warning = function(w) invokeRestart("muffleWarning"))
+ if (isTRUE(enc)) {
+ crypto$encrypted <- c(crypto$encrypted, room_id)
+ matrix_crypto_save_encrypted(crypto)
+ }
+ isTRUE(enc)
+}
+
+# The m.room.message content an encrypted send wraps. Deliberately the
+# same shape mx_send_text() PUTs in the clear -- markdown becomes
+# formatted_body, mentions become pills plus m.mentions -- so a room's
+# encryption state changes the envelope and nothing a reader sees.
+matrix_crypto_content <- function(text, msgtype = "m.text", markdown = FALSE,
+ mentions = NULL) {
+ content <- list(msgtype = msgtype, body = text)
+ if (isTRUE(markdown) || length(mentions)) {
+ html <- mx.client::mx_markdown_to_html(text)
+ if (length(mentions)) {
+ html <- mx.client::mx_pill_mentions(html, mentions)
+ }
+ content$format <- "org.matrix.custom.html"
+ content$formatted_body <- html
+ }
+ if (length(mentions)) {
+ content[["m.mentions"]] <- list(user_ids = as.list(mentions))
+ }
+ content
+}
+
+# Encrypt and send. Errors propagate, the same way the cleartext path's do
+# -- a caller that wants "no event id" on failure wraps the call, and one
+# that does not should hear about it.
+#
+# Membership discovery is not best-effort. mx_send_encrypted() derives the
+# recipient devices from member_ids, so an empty list means it shares the
+# room key with nobody, posts the m.room.encrypted event anyway, and
+# returns an event id. On a fresh outbound session nobody in the room can
+# read that message, while the caller records a successful send. Swallowing
+# the lookup error bought exactly that outcome, so it is gone: skipping
+# individual devices that cannot be verified is mx.client's decision to
+# make, and it is a different one.
+matrix_crypto_send <- function(crypto, mx, room_id, text, msgtype = "m.text",
+ markdown = FALSE, mentions = NULL) {
+ members <- mx.api::mx_room_members(mx.client::mx_client_session(mx),
+ room_id)
+ if (!length(members)) {
+ stop("chat.api: no members found for ", room_id,
+ ". Refusing to send an encrypted event whose room key would ",
+ "reach nobody.", call. = FALSE)
+ }
+ content <- matrix_crypto_content(text, msgtype = msgtype,
+ markdown = markdown, mentions = mentions)
+ res <- mx.client::mx_send_encrypted(mx, crypto$account, crypto$sessions,
+ room_id, content, crypto$store,
+ member_ids = members)
+ crypto$sessions <- res$sessions
+ res$event_id
+}
diff --git a/R/matrix.R b/R/matrix.R
index 9954395..891a987 100644
--- a/R/matrix.R
+++ b/R/matrix.R
@@ -33,6 +33,59 @@
#' refreshed config lands back in the client, so the next poll and any
#' send use the new token. FALSE lets \code{mx_error_M_UNKNOWN_TOKEN}
#' propagate.
+#' @param e2ee Logical. Own Olm/Megolm state in the adapter, so
+#' \code{\link{chat_send}} encrypts for rooms that advertise
+#' \code{m.room.encryption} and \code{\link{chat_poll}} decrypts
+#' inbound. FALSE (the default) is the previous behaviour exactly:
+#' cleartext only, no crypto state, and \code{e2ee} stays FALSE in
+#' \code{\link{chat_capabilities}}. Requires the 'mx.crypto' package,
+#' which needs a Rust toolchain, and both \code{user_id} and
+#' \code{device_id} on the wrapped config.
+#'
+#' Three things change when it is on. The crypto state is built on
+#' first use rather than here: from \code{\link{chat_poll}} that means
+#' key publication happens after the sync, so a relogin has already
+#' replaced a rejected token, while a \code{\link{chat_send}} that runs
+#' before any poll publishes with the token it has -- the same exposure
+#' any direct send already carries, since \code{mx_with_relogin()}
+#' wraps only the sync. The sync cursor is held back until the crypto
+#' state is safe, on disk and on the client, so a sync carrying a room
+#' key is not marked consumed until that key is stored. And attachments
+#' are refused in encrypted rooms, because Matrix media uploads are not
+#' encrypted by this adapter -- \code{\link{chat_capabilities}} reports
+#' \code{files = FALSE} to match.
+#' @param crypto_store Character or NULL. Directory holding the pickled
+#' Olm account and sessions. NULL derives it from \code{app} via
+#' \code{mx.client::mx_crypto_store_dir()}, with a per-device
+#' subdirectory, so moving a config does not cost the device identity
+#' and two bots under one app namespace do not share an Olm account.
+#'
+#' A Matrix device has one Olm identity for its whole life, and three
+#' things hold that. The store records the \code{(user_id, device_id)}
+#' it belongs to and refuses to open for a different one. Within a
+#' process, one device gets one context and one store, so pointing a
+#' second store at it is an error rather than a second account. And on
+#' first use the account's keys are checked against the ones the
+#' homeserver already has for that \code{device_id} -- the only one of
+#' the three that survives a restart, and so the one that catches a
+#' store swapped between runs. Log in again for a new \code{device_id}
+#' rather than re-homing an existing one.
+#'
+#' That last check distinguishes three cases: no published device is a
+#' first run, a published device whose keys match is this account, and
+#' a published device that either differs or fails signature
+#' verification is an error. A \code{/keys/query} that returns a
+#' \code{failures} map is an error too, since the empty result beside
+#' it is an unanswered question rather than an absent device. It cannot
+#' see a homeserver that omits the device deliberately, which is
+#' indistinguishable from a first run without key pinning or
+#' cross-signing.
+#'
+#' A Windows drive-relative \code{crypto_store} (\code{"C:store"}) is
+#' rejected. It names a directory relative to the current directory of
+#' that drive, which cannot be read here, so two such paths cannot be
+#' told apart and two stores could share one context. Pass an absolute
+#' path.
#' @param .sync Testing seam: replacement for
#' \code{mx.client::mx_sync_update}. Leave NULL in production.
#' @param .extract Testing seam: replacement for
@@ -48,6 +101,17 @@
#' \code{mx.api::mx_typing}. Leave NULL in production. It is resolved
#' when \code{chat_typing()} is called, not here, so a NULL seam costs
#' nothing on installs without mx.api.
+#' @param .crypto Testing seam: a named list overriding any of the
+#' adapter's four crypto operations -- \code{init}, \code{encrypted},
+#' \code{send}, \code{decrypt}. Leave NULL in production. Supplying
+#' \code{init} is what lets \code{e2ee = TRUE} be tested on a runner
+#' with neither mx.crypto nor a Rust toolchain; E2EE is the one part of
+#' this adapter with no honest way to reach a real homeserver from a
+#' test.
+#' @param .save Testing seam: replacement for
+#' \code{mx.client::mx_client_save}. Leave NULL in production. Only
+#' reached on an \code{e2ee} client, which writes the sync cursor after
+#' the crypto state rather than inside the sync.
#' @return A \code{chat_client} of class \code{chat_matrix}.
#' \code{\link{chat_poll}} on this class returns \code{first_run} and
#' \code{client} alongside \code{messages}, \code{cursor}, and
@@ -59,9 +123,10 @@
#' poll cycle.
#' @export
chat_matrix <- function(app = NULL, path = NULL, save_cursor = TRUE,
- mx = NULL, relogin = TRUE, .sync = NULL,
- .extract = NULL, .send = NULL, .media = NULL,
- .typing = NULL) {
+ mx = NULL, relogin = TRUE, e2ee = FALSE,
+ crypto_store = NULL, .sync = NULL, .extract = NULL,
+ .send = NULL, .media = NULL, .typing = NULL,
+ .crypto = NULL, .save = NULL) {
seams <- list(.sync, .extract, .send, .media)
if ((is.null(mx) || any(vapply(seams, is.null, logical(1)))) &&
!requireNamespace("mx.client", quietly = TRUE)) {
@@ -78,13 +143,42 @@ chat_matrix <- function(app = NULL, path = NULL, save_cursor = TRUE,
}
env <- new.env(parent = emptyenv())
env$mx <- mx
+ # Crypto state lives on the same env as the client, so a consumer
+ # holds one object and never touches Olm or Megolm itself. NULL when
+ # e2ee is off, which is the default and is exactly the pre-existing
+ # behaviour.
+ # No crypto context yet, even with e2ee on. Initialization publishes
+ # keys, which is an authenticated request, and the stored token may
+ # already be rejected at process start. Doing it here put that upload
+ # ahead of any relogin, so the constructor threw before the poll loop
+ # existed and every restart repeated with the same dead token. It is
+ # built on first use instead, off a config that has just worked. See
+ # matrix_crypto_require().
+ env$crypto <- NULL
+ # The identity check runs now rather than at first use, so a config
+ # that can never key a store fails while the caller is still looking
+ # at the constructor.
+ if (isTRUE(e2ee)) {
+ matrix_crypto_identity(mx)
+ }
structure(list(env = env, app = app, save_cursor = isTRUE(save_cursor),
- relogin = isTRUE(relogin),
+ relogin = isTRUE(relogin), e2ee = isTRUE(e2ee),
+ crypto_store = crypto_store,
sync_fn = .sync %||% mx.client::mx_sync_update,
extract_fn = .extract %||% mx.client::mx_extract_text_events,
send_fn = .send %||% mx.client::mx_send_text,
media_fn = .media %||% mx.client::mx_send_media,
- typing_fn = .typing),
+ # Left unresolved, like typing_fn. The four seams above
+ # are only defaulted when their seam is NULL, and R's
+ # lazy `%||%` never forces the mx.client side when one
+ # is supplied -- which is what makes the documented
+ # four-seams-without-mx.client configuration work.
+ # Defaulting this one here forced mx.client on every
+ # client, cleartext ones included, and broke exactly
+ # that configuration. It is resolved where it is used,
+ # on the deferred-save path, which only e2ee reaches.
+ save_fn = .save,
+ typing_fn = .typing, crypto_ops = matrix_crypto_ops(.crypto)),
class = c("chat_matrix", "chat_client"))
}
@@ -109,6 +203,42 @@ matrix_event_times <- function(sync) {
out
}
+# Each timeline event's position in the sync, by event id. Walks the same
+# structure matrix_event_times() does, and for the same reason: it is the
+# only place the homeserver's own ordering survives, since the cleartext
+# extractor and the decrypt step each return their own events and neither
+# knows about the other's.
+#
+# Not origin_server_ts. Two events in one room can share a millisecond,
+# and a homeserver's clock is not the ordering the room agreed on.
+matrix_event_order <- function(sync) {
+ out <- list()
+ i <- 0L
+ for (room in sync$rooms$join %||% list()) {
+ for (ev in room$timeline$events %||% list()) {
+ i <- i + 1L
+ if (!is.null(ev$event_id)) {
+ out[[as.character(ev$event_id)]] <- i
+ }
+ }
+ }
+ out
+}
+
+# Sort messages into sync order. Unpositioned events keep their arrival
+# order at the end rather than being dropped or interleaved by guesswork,
+# and the tie-break on index makes the result deterministic either way.
+matrix_order_messages <- function(messages, positions) {
+ if (length(messages) < 2L) {
+ return(messages)
+ }
+ pos <- vapply(messages, function(m) {
+ p <- positions[[as.character(m$id)]]
+ if (is.null(p)) Inf else as.numeric(p)
+ }, numeric(1))
+ messages[order(pos, seq_along(pos))]
+}
+
# Matrix msgtype -> the contract's kind vocabulary. chat_send maps the
# same pair in the other direction, so a message that makes a round trip
# keeps its kind. Leaving m.text on the record would make every Matrix
@@ -129,9 +259,23 @@ chat_poll.chat_matrix <- function(client, since = NULL, timeout = NULL, ...) {
if (!is.null(since)) {
client$env$mx$sync_token <- since
}
+ # With E2EE on the cursor is written after the crypto state, not
+ # inside the sync. mx_sync_update(save = TRUE) commits the advanced
+ # token before anything parses the response, which is what makes a
+ # malformed event survivable -- crash, restart, resume past it. But a
+ # sync carrying a room key is only consumed once that key is on disk,
+ # and a crash between the two loses it permanently: the key is never
+ # re-sent, so every later message in that room is undecryptable.
+ #
+ # So the poison-pill protection is traded for durability, and only on
+ # e2ee clients. What is left in the window is one sync's worth of
+ # replay, which is idempotent -- room keys and Megolm sessions are
+ # keyed by session id.
+ defer_save <- isTRUE(client$e2ee) && isTRUE(client$save_cursor)
do_sync <- function(mx) {
client$sync_fn(mx, timeout = as.integer((timeout %||% 0) * 1000),
- save = client$save_cursor, app = client$app, ...)
+ save = isTRUE(client$save_cursor) && !defer_save,
+ app = client$app, ...)
}
# The relogin wrapper hands the retry a re-authenticated config, so
# the sync has to run off whatever config it is given rather than
@@ -144,9 +288,46 @@ chat_poll.chat_matrix <- function(client, since = NULL, timeout = NULL, ...) {
} else {
do_sync(client$env$mx)
}
- client$env$mx <- res$client
+ # On an e2ee client the advanced cursor is held back until the crypto
+ # state is safe, in memory as well as on disk. Keeping the cursor off
+ # disk but live on the client only moves the skip: a caller that
+ # catches the decrypt error and polls the same client again resumes
+ # from the token the failed sync produced, and the room keys in it are
+ # gone for good. The refreshed credentials are kept either way -- a
+ # relogin is not what makes a sync consumed.
+ if (isTRUE(client$e2ee)) {
+ held <- res$client
+ held$sync_token <- client$env$mx$sync_token
+ client$env$mx <- held
+ } else {
+ client$env$mx <- res$client
+ }
+
+ # Crypto runs before the cursor is committed and after the sync has
+ # succeeded: after, so a relogin has already replaced a rejected token
+ # and initialization publishes keys with one that works; before, so a
+ # sync whose room keys did not reach disk is not marked consumed.
+ #
+ # Errors propagate. mx.client already skips an individual event it has
+ # no Megolm session for, so a throw here is the other kind of failure
+ # -- to-device processing, or the session save itself -- and swallowing
+ # it while keeping the advanced cursor acknowledged a sync whose keys
+ # were lost.
+ dec <- list()
+ if (isTRUE(client$e2ee)) {
+ crypto <- matrix_crypto_require(client)
+ dec <- client$crypto_ops$decrypt(crypto, res$sync, client$env$mx)
+ if (defer_save) {
+ save_fn <- client$save_fn %||% mx.client::mx_client_save
+ res$client <- save_fn(res$client, app = client$app)
+ }
+ # Nothing above threw, so the sync is consumed and its cursor
+ # becomes the live one.
+ client$env$mx <- res$client
+ }
event_ts <- matrix_event_times(res$sync)
+ event_pos <- matrix_event_order(res$sync)
recs <- client$extract_fn(res$sync, self_id = res$client$user_id)
messages <- lapply(recs, function(r) {
# ts is the event's own origin_server_ts, from the record when
@@ -169,6 +350,34 @@ chat_poll.chat_matrix <- function(client, since = NULL, timeout = NULL, ...) {
mentions = unlist(r$mentions, use.names = FALSE),
raw = r)
})
+ # Decrypted traffic folds in alongside the cleartext, in the same
+ # chat_message shape, so a consumer cannot tell which arrived
+ # encrypted except by reading $encrypted. That is the whole point of
+ # owning crypto here: corteza used to run its own decrypt off $raw and
+ # concatenate the results itself.
+ for (d in dec) {
+ ms <- d$ts %||% event_ts[[as.character(d$event_id)]]
+ messages[[length(messages) + 1L]] <- chat_message(
+ id = as.character(d$event_id),
+ channel = as.character(d$room_id),
+ sender = as.character(d$sender),
+ body = as.character(d$body),
+ ts = if (is.null(ms)) as.POSIXct(NA) else
+ as.POSIXct(ms / 1000, origin = "1970-01-01"),
+ markup = "plain", kind = matrix_kind(d$msgtype),
+ self = isTRUE(d$is_self),
+ mentions = unlist(d$mentions, use.names = FALSE),
+ encrypted = TRUE,
+ sender_verified = isTRUE(d$sender_verified),
+ raw = d)
+ }
+ # Back into the order the homeserver sent them. Appending the
+ # decrypted events put every one of them after every cleartext one,
+ # so an encrypted message followed by a plain reply came out
+ # backwards -- which reorders a room's commands against the messages
+ # they act on. Anything the sync did not position sorts last, in the
+ # order it arrived.
+ messages <- matrix_order_messages(messages, event_pos)
# first_run says the sync ran without a stored cursor, so these
# messages are the homeserver's backfill baseline, not new traffic.
# A consumer that drops it replays its whole history as fresh mail
@@ -180,8 +389,8 @@ chat_poll.chat_matrix <- function(client, since = NULL, timeout = NULL, ...) {
# with the token the homeserver just rejected.
#
# raw carries the full sync response for Matrix-specific consumers
- # (invites, reactions, E2EE decryption) that the generic contract
- # does not model yet
+ # (invites, reactions) that the generic contract does not model yet.
+ # E2EE no longer needs it.
list(messages = messages, cursor = res$client$sync_token,
first_run = isTRUE(res$first_run), client = res$client,
raw = res$sync)
@@ -194,6 +403,25 @@ chat_send.chat_matrix <- function(client, channel, text,
identity = NULL, files = NULL,
kind = "message", notify = TRUE, ...) {
markup <- match.arg(markup)
+ # Resolved before anything is uploaded or posted. The encryption
+ # question used to be asked after the attachment loop had already run,
+ # which put the files on the homeserver in the clear before the text
+ # took the Megolm path -- and an attachment-only send never asked at
+ # all.
+ crypto <- matrix_crypto_require(client)
+ encrypted <- !is.null(crypto) &&
+ client$crypto_ops$encrypted(crypto, client$env$mx, channel)
+ if (encrypted && !is.null(files)) {
+ # mx_send_media() posts an ordinary cleartext m.file event: the
+ # upload is not encrypted, and neither is the URL. There is no
+ # encrypted-attachment path to fall back to, so this refuses
+ # rather than leaking. chat_capabilities()$files reports FALSE on
+ # an e2ee client for the same reason.
+ stop("chat.api: cannot send attachments to the encrypted room ",
+ channel, ". Matrix media uploads are not encrypted by this ",
+ "adapter, and posting them would put the file on the ",
+ "homeserver in the clear.", call. = FALSE)
+ }
media_ids <- character()
if (!is.null(files)) {
for (f in files) {
@@ -217,6 +445,17 @@ chat_send.chat_matrix <- function(client, channel, text,
# attachment's echo as somebody else's message: corteza recognizes
# its own traffic by event id.
if (!length(media_ids) || any(nzchar(text))) {
+ # markup and mentions carry through: the encrypted branch builds
+ # the same m.room.message content the cleartext one does, so a
+ # markdown send renders the same either way. Only the envelope
+ # differs.
+ if (encrypted) {
+ event <- client$crypto_ops$send(crypto, client$env$mx, channel,
+ text, msgtype = msgtype,
+ markdown = identical(markup, "markdown"),
+ mentions = list(...)$mentions)
+ return(invisible(c(media_ids, as.character(event))))
+ }
event <- client$send_fn(client$env$mx, text, room = channel,
msgtype = msgtype,
markdown = identical(markup, "markdown"), ...)
@@ -275,16 +514,24 @@ chat_capabilities.chat_matrix <- function(client, ...) {
# and mx_extract_text_events filters to m.text, so an m.reaction can
# be neither sent nor received here.
#
- # e2ee is FALSE because chat_send goes through mx_send_text, which
- # PUTs a cleartext m.room.message no matter what the room's
- # encryption state says, and chat_poll never sees an
- # m.room.encrypted event. Reporting TRUE off an installed mx.crypto
- # invites a consumer to hand this adapter a secret for an encrypted
- # room and have it land on the homeserver in the clear. Flip it when
- # the adapter routes through mx_send_encrypted and decrypts inbound.
+ # e2ee answers for this client, not for Matrix and not for the
+ # installed packages: it is TRUE when this client was built with
+ # e2ee = TRUE, which is what routes sends through mx_send_encrypted
+ # and decrypts inbound. Reporting TRUE off an installed mx.crypto
+ # would invite a consumer to hand a secret to a client that PUTs it in
+ # the clear. It reads the setting rather than the crypto context
+ # because the context is built on first use, and a capability that
+ # flipped after the first poll would be worse than either answer.
+ #
+ # files is FALSE on an e2ee client. mx_send_media() posts a cleartext
+ # m.file event whatever the room's encryption state says, and this
+ # adapter has no encrypted-attachment path, so chat_send() refuses
+ # attachments to an encrypted room. TRUE would advertise something
+ # that fails in exactly the rooms such a client exists for.
list(threads = FALSE, thread_replies = FALSE, edits = FALSE,
- reactions = FALSE, files = TRUE, typing = TRUE, e2ee = FALSE,
- identity_override = FALSE, markup_dialects = c("plain", "markdown"),
+ reactions = FALSE, files = !isTRUE(client$e2ee), typing = TRUE,
+ e2ee = isTRUE(client$e2ee), identity_override = FALSE,
+ markup_dialects = c("plain", "markdown"),
max_message_bytes = NA_integer_)
}
diff --git a/inst/tinytest/test_irc.R b/inst/tinytest/test_irc.R
index 0651543..0c1a0d8 100644
--- a/inst/tinytest/test_irc.R
+++ b/inst/tinytest/test_irc.R
@@ -2,6 +2,13 @@
# (serverSocket + socketAccept, R >= 4.0). Skips where binding a
# localhost port is not permitted.
+# Defined before its first use, and defined at all. Base R only grew
+# `%||%` in 4.4.0, so the line below used to borrow base's silently: fine
+# on the R this usually runs against, an error on the R 4.0 this package
+# claims to support, and the Ubuntu runner picked distro R 4.3.3 one day
+# and said so.
+`%||%` <- function(a, b) if (is.null(a)) b else a
+
ss <- tryCatch(serverSocket(0L), error = function(e) NULL)
if (!is.null(ss)) {
port <- summary(ss)$port %||% NULL
@@ -14,8 +21,6 @@ if (!is.null(ss)) {
}
}
-`%||%` <- function(a, b) if (is.null(a)) b else a
-
if (!is.null(ss)) {
cl <- chat_irc("127.0.0.1", port, nick = "testbot", channels = "#lab")
srv <- socketAccept(ss, blocking = FALSE, open = "r+")
diff --git a/inst/tinytest/test_matrix.R b/inst/tinytest/test_matrix.R
index dc7bd9a..fd23a77 100644
--- a/inst/tinytest/test_matrix.R
+++ b/inst/tinytest/test_matrix.R
@@ -5,9 +5,12 @@
# The half that pins the adapter to mx.client's real signatures lives in
# test_matrix_mxclient.R, which announces its skip.
-fake_mx <- function(sync_token = NULL) {
- list(user_id = "@bot:ex", server = "https://ex.invalid",
- sync_token = sync_token)
+# device_id is here because an Olm account belongs to a device: an e2ee
+# client refuses a config that cannot name one.
+fake_mx <- function(sync_token = NULL, user_id = "@bot:ex",
+ device_id = "DEV1") {
+ list(user_id = user_id, server = "https://ex.invalid",
+ device_id = device_id, sync_token = sync_token)
}
# An extractor-shaped record: the seven fields mx_extract_text_events
@@ -37,16 +40,32 @@ wrap_sync <- function(events, room = "!room:ex") {
# A fully seamed client. recs is what the .extract seam hands back, so
# the mapping under test never depends on which mx.client is installed.
+# send and media are named parameters rather than `...` pass-throughs so
+# a test can watch the cleartext path without colliding with the defaults
+# this helper already supplies.
+#
+# save is seamed too. An e2ee client defers the cursor write out of the
+# sync, and the real writer is mx.client::mx_client_save -- correct in
+# production, and the one thing that would make this file need mx.client
+# after all.
seam_client <- function(recs = list(), sync = wrap_sync(list()),
first_run = FALSE, token = "s1", mx = fake_mx(),
- record = NULL, ...) {
- chat_matrix(mx = mx,
+ record = NULL, send = function(...) "$id",
+ media = function(...) NULL,
+ save = function(client, ...) client, ...) {
+ chat_matrix(mx = mx, .save = save,
.sync = function(client, ...) {
if (!is.null(record)) {
record$sync[[length(record$sync) + 1L]] <- c(
list(client = client), list(...))
}
- list(sync = sync, client = fake_mx(token), first_run = first_run)
+ # The post-sync config keeps the identity it went in with. A
+ # real sync (or relogin) rotates the token, never the
+ # user_id/device_id, and crypto is keyed on those.
+ list(sync = sync,
+ client = fake_mx(token, user_id = client$user_id,
+ device_id = client$device_id),
+ first_run = first_run)
},
.extract = function(sync_resp, self_id, ...) {
if (!is.null(record)) {
@@ -55,7 +74,7 @@ seam_client <- function(recs = list(), sync = wrap_sync(list()),
}
recs
},
- .send = function(...) "$id", .media = function(...) NULL, ...)
+ .send = send, .media = media, ...)
}
# ---- Constructor ----
@@ -422,11 +441,12 @@ expect_false(caps$thread_replies)
# filter means an m.reaction can be neither sent nor received here
expect_false(caps$reactions)
expect_false("chat_react" %in% getNamespaceExports("chat.api"))
-# e2ee: chat_send goes through mx_send_text, which PUTs a cleartext
-# m.room.message whatever the room's encryption state says, and
-# chat_poll never sees an m.room.encrypted event. Reporting TRUE off an
-# installed mx.crypto invites a consumer to hand this adapter a secret
-# for an encrypted room and have it land on the homeserver in the clear.
+# e2ee answers for this client. `s` was built without e2ee = TRUE, so it
+# holds no crypto context and chat_send goes through mx_send_text, which
+# PUTs a cleartext m.room.message whatever the room's encryption state
+# says. Reporting TRUE off an installed mx.crypto would invite a consumer
+# to hand this client a secret and have it land on the homeserver in the
+# clear. The e2ee = TRUE half is asserted in the E2EE block below.
expect_false(caps$e2ee)
expect_true(is.logical(caps$e2ee) && length(caps$e2ee) == 1L)
expect_true(caps$files)
@@ -464,3 +484,784 @@ local({
expect_identical(only, "$media3")
expect_identical(sent$text, before)
})
+
+# ---- E2EE ----
+# The .crypto seam stands in for the whole crypto module, so these run
+# with neither mx.crypto nor a Rust toolchain. What is under test is the
+# adapter's routing: which path a send takes, when crypto state is built
+# and persisted, and whether decrypted events reach the caller as
+# ordinary chat_message records in the order the homeserver sent them.
+
+# A fake crypto context plus ops that record what the adapter asked for.
+# Contexts are interned per identity, so a fresh fake starts by dropping
+# whatever the previous one interned; otherwise every case after the first
+# would silently reuse an earlier fake's context.
+fake_crypto <- function(encrypted_rooms = "!enc:ex", decrypted = list(),
+ event_id = "$enc1", decrypt_error = FALSE,
+ lookup_error = FALSE, init_error = FALSE) {
+ chat.api:::matrix_crypto_forget()
+ log <- new.env(parent = emptyenv())
+ log$init <- list()
+ log$asked <- character()
+ log$sent <- list()
+ log$decrypted <- 0L
+ ctx <- new.env(parent = emptyenv())
+ ctx$encrypted <- encrypted_rooms
+ ops <- list(
+ init = function(mx, store = NULL, app = NULL) {
+ log$init[[length(log$init) + 1L]] <- list(mx = mx, store = store,
+ app = app)
+ if (isTRUE(init_error)) {
+ stop("keys could not be published")
+ }
+ ctx
+ },
+ encrypted = function(crypto, mx, room_id) {
+ log$asked <- c(log$asked, room_id)
+ if (isTRUE(lookup_error)) {
+ stop("cannot determine the encryption state of ", room_id)
+ }
+ room_id %in% crypto$encrypted
+ },
+ send = function(crypto, mx, room_id, text, msgtype = "m.text",
+ markdown = FALSE, mentions = NULL) {
+ log$sent[[length(log$sent) + 1L]] <- list(
+ room_id = room_id, text = text, msgtype = msgtype,
+ markdown = markdown, mentions = mentions, mx = mx)
+ event_id
+ },
+ decrypt = function(crypto, sync, mx) {
+ log$decrypted <- log$decrypted + 1L
+ if (isTRUE(decrypt_error)) {
+ stop("no session for that megolm stream")
+ }
+ decrypted
+ })
+ list(ops = ops, log = log, ctx = ctx)
+}
+
+# e2ee is opt-in. Without it there is no crypto context, so nothing the
+# adapter does can consult one -- this is the pre-E2EE behaviour exactly.
+plain <- seam_client()
+expect_null(plain$env$crypto)
+expect_false(plain$e2ee)
+expect_false(chat_capabilities(plain)$e2ee)
+expect_true(chat_capabilities(plain)$files)
+
+# init is not called either: an unused crypto module must not touch the
+# store or publish keys.
+f <- fake_crypto()
+off <- seam_client(e2ee = FALSE, .crypto = f$ops)
+chat_poll(off)
+chat_send(off, "!enc:ex", "hi")
+expect_identical(length(f$log$init), 0L)
+expect_null(off$env$crypto)
+
+# ---- Crypto is built on first use, not at construction ----
+# Initialization publishes keys, which is an authenticated request. The
+# stored token may already be rejected at process start -- that is what
+# mx_with_relogin() is for -- so building here put the upload ahead of
+# any relogin. chat_matrix() threw, the poll loop never ran, and every
+# restart repeated with the same dead token.
+
+f <- fake_crypto()
+enc <- seam_client(e2ee = TRUE, .crypto = f$ops)
+expect_identical(length(f$log$init), 0L)
+expect_null(enc$env$crypto)
+# The capability answers from the setting, not the context, so it does not
+# flip after the first poll.
+expect_true(chat_capabilities(enc)$e2ee)
+# ... and files is FALSE, because mx_send_media posts a cleartext m.file
+# event whatever the room's encryption state says.
+expect_false(chat_capabilities(enc)$files)
+
+# A construction that would have died now survives to the first poll,
+# which is where relogin gets its chance.
+expect_silent(seam_client(e2ee = TRUE, .crypto = fake_crypto(init_error = TRUE)$ops))
+expect_error(chat_poll(seam_client(e2ee = TRUE,
+ .crypto = fake_crypto(init_error = TRUE)$ops)),
+ "keys could not be published")
+
+# The first poll builds it off the post-sync credentials rather than the
+# ones the client was constructed with -- that is the whole point of
+# waiting, since a relogin during the sync is what replaces a rejected
+# token. Asserted on the access token, not the cursor: the cursor is
+# deliberately still the pre-sync one at this point, held back until the
+# crypto state is safe.
+f <- fake_crypto()
+enc <- chat_matrix(mx = fake_mx(), e2ee = TRUE, .crypto = f$ops,
+ .sync = function(client, ...) {
+ c2 <- fake_mx("advanced")
+ c2$token <- "after-relogin"
+ list(sync = wrap_sync(list()), client = c2,
+ first_run = FALSE)
+ },
+ .extract = function(...) list(),
+ .send = function(...) "$id", .media = function(...) NULL,
+ .save = function(client, ...) client)
+chat_poll(enc)
+expect_identical(length(f$log$init), 1L)
+expect_identical(f$log$init[[1L]]$mx$token, "after-relogin")
+expect_true(is.environment(enc$env$crypto))
+# Later polls reuse it.
+chat_poll(enc)
+expect_identical(length(f$log$init), 1L)
+
+# A send builds it too, for a client that sends before it polls.
+f <- fake_crypto()
+enc <- seam_client(e2ee = TRUE, .crypto = f$ops)
+chat_send(enc, "!enc:ex", "secret")
+expect_identical(length(f$log$init), 1L)
+
+# ---- The store is bound to a device identity ----
+# An Olm account belongs to a device, not a user. A config that cannot
+# name one cannot key a store, and the check runs at construction so the
+# caller hears about it there.
+expect_error(chat_matrix(mx = list(user_id = "@bot:ex"), e2ee = TRUE,
+ .sync = function(...) NULL,
+ .extract = function(...) list(),
+ .send = function(...) "$id",
+ .media = function(...) NULL,
+ .crypto = fake_crypto()$ops),
+ "user_id and device_id")
+expect_error(chat_matrix(mx = list(device_id = "DEV1"), e2ee = TRUE,
+ .sync = function(...) NULL,
+ .extract = function(...) list(),
+ .send = function(...) "$id",
+ .media = function(...) NULL,
+ .crypto = fake_crypto()$ops),
+ "user_id and device_id")
+# e2ee off does not need one: a cleartext client has no account to key.
+expect_silent(seam_client(mx = list(user_id = "@bot:ex")))
+
+# The cache key is the device identity and nothing else. Matrix gives a
+# device one ed25519 and one curve25519 key for the life of that
+# device_id, so a key that also carried the store let two stores mint two
+# Olm accounts -- two identity keys for one device.
+k <- chat.api:::matrix_crypto_cache_key
+expect_false(identical(k(fake_mx(user_id = "@a:ex")),
+ k(fake_mx(user_id = "@b:ex"))))
+expect_false(identical(k(fake_mx(device_id = "D1")),
+ k(fake_mx(device_id = "D2"))))
+expect_identical(k(fake_mx()), k(fake_mx()))
+# Same device, different token or cursor: still one key.
+expect_identical(k(fake_mx(sync_token = "s1")), k(fake_mx()))
+
+# Store requests normalize, so two spellings of one directory are one
+# request rather than two contexts writing over each other's pickles.
+spec <- chat.api:::matrix_crypto_store_spec
+expect_identical(spec("/tmp/store-a"), spec("/tmp/store-a/"))
+expect_identical(spec("/tmp/store-a"), spec("/tmp/store-a///"))
+expect_false(identical(spec("/tmp/store-a"), spec("/tmp/store-b")))
+
+# Dot segments fold whether or not the directory exists. normalizePath()
+# only folds them for a path that does, and a store's first use is
+# exactly when it does not -- so two calls for one directory would have
+# disagreed depending on whether it had been created in between.
+n <- chat.api:::matrix_normalize_path
+expect_identical(n("/missing/a/../b"), n("/missing/b"))
+expect_identical(n("/missing/./b"), n("/missing/b"))
+expect_identical(n("/missing/a/b/../.."), n("/missing"))
+# Roots survive, including a Windows drive root -- the trailing-slash
+# strip this replaced turned "C:/" into "C:".
+expect_identical(n("/"), "/")
+expect_identical(n("C:/"), "C:/")
+expect_identical(n("C:/x/../y"), "C:/y")
+expect_identical(n("C:\\x\\y"), "C:/x/y")
+# There is nothing above a root to climb to.
+expect_identical(n("/../x"), "/x")
+
+# A UNC share is not the local path that happens to spell the same. On
+# Windows //server/share/x is a remote mount and /server/share/x is a
+# local directory; collapsing every leading slash run made them one spec,
+# so a request for one store would have been handed the other's context.
+expect_identical(n("//server/share/x"), "//server/share/x")
+expect_identical(n("\\\\server\\share\\x"), "//server/share/x")
+expect_false(identical(n("//server/share/x"), n("/server/share/x")))
+# Three or more leading slashes are POSIX, not UNC, and collapse.
+expect_identical(n("///server/share/x"), "/server/share/x")
+# Dot segments fold inside a UNC path, and not past the share.
+expect_identical(n("//server/share/a/../x"), "//server/share/x")
+expect_identical(n("//server/share/../../x"), "//server/share/x")
+# Two shares on one host are two roots.
+expect_false(identical(n("//server/one/x"), n("//server/two/x")))
+
+# A drive-relative path is refused rather than folded. Windows resolves
+# "C:store" against the current directory of drive C, which R cannot
+# read, so "C:../x", "C:x" and "C:a/../../x" are three directories that
+# no amount of folding here can tell apart -- and two of them sharing a
+# spec is two stores sharing a context.
+expect_error(n("C:relative"), "drive-relative")
+expect_error(n("C:../x"), "drive-relative")
+expect_error(n("C:a/../../x"), "drive-relative")
+expect_error(n("C:"), "drive-relative")
+expect_error(n("c:store"), "drive-relative")
+expect_error(spec("C:relative"), "drive-relative")
+# An absolute drive path is fine, and is what the error asks for.
+expect_identical(n("C:/relative"), "C:/relative")
+# ~ expands, and a relative path resolves against the caller's directory
+# rather than merging with an unrelated one of the same name.
+expect_true(startsWith(n("~/x"), "/"))
+expect_identical(n("rel/x"), n(file.path(getwd(), "rel/x")))
+expect_true(startsWith(n("rel/x"), "/"))
+# Directories that do exist still fold, which is the common case.
+local({
+ d <- file.path(tempfile("specdir"), "s")
+ dir.create(d, recursive = TRUE)
+ on.exit(unlink(dirname(d), recursive = TRUE))
+ expect_identical(spec(d), spec(file.path(dirname(d), "..",
+ basename(dirname(d)), "s")))
+ # ... and the same path gives the same spec before and after it
+ # exists, which is the property the whole comparison rests on.
+ gone <- file.path(tempfile("specgone"), "s")
+ before <- spec(gone)
+ dir.create(gone, recursive = TRUE)
+ on.exit(unlink(dirname(gone), recursive = TRUE), add = TRUE)
+ expect_identical(before, spec(gone))
+})
+# A deferred store is a stable request too: two clients that both defer
+# resolve the same way, and the app is what distinguishes them.
+expect_identical(spec(NULL), spec(NULL))
+expect_false(identical(spec(NULL, "a"), spec(NULL, "b")))
+
+# Two default clients for different bots get two contexts.
+f <- fake_crypto()
+chat_poll(seam_client(e2ee = TRUE, .crypto = f$ops,
+ mx = fake_mx(user_id = "@one:ex")))
+chat_poll(seam_client(e2ee = TRUE, .crypto = f$ops,
+ mx = fake_mx(user_id = "@two:ex")))
+expect_identical(length(f$log$init), 2L)
+
+# ---- One Olm account per identity ----
+# A consumer that derives a fresh client per use -- corteza does, so the
+# rotating access token is never cached -- must not mint a second Olm
+# account each time. Interning is what reconciles a short-lived client
+# with a long-lived crypto identity.
+
+f <- fake_crypto()
+a <- seam_client(e2ee = TRUE, app = "cornelius", .crypto = f$ops)
+b <- seam_client(e2ee = TRUE, app = "cornelius", .crypto = f$ops)
+chat_poll(a)
+chat_poll(b)
+expect_identical(length(f$log$init), 1L)
+expect_true(identical(a$env$crypto, b$env$crypto))
+
+# One explicit store, asked for twice, is one context -- including when
+# the second spelling of it differs.
+f <- fake_crypto()
+chat_send(seam_client(e2ee = TRUE, crypto_store = "/tmp/store-a",
+ .crypto = f$ops), "!enc:ex", "x")
+chat_send(seam_client(e2ee = TRUE, crypto_store = "/tmp/store-a/",
+ .crypto = f$ops), "!enc:ex", "x")
+expect_identical(length(f$log$init), 1L)
+
+# A second store for a device that already has one is refused. Two stores
+# would be two Olm accounts, and the spec gives a device_id one identity.
+# It is not silently ignored either: the caller asked for a store and
+# would otherwise have got a different one without being told.
+expect_error(chat_send(seam_client(e2ee = TRUE, crypto_store = "/tmp/store-b",
+ .crypto = f$ops), "!enc:ex", "x"),
+ "already has a crypto store")
+expect_identical(length(f$log$init), 1L)
+# The default store and an explicit one conflict the same way.
+expect_error(chat_poll(seam_client(e2ee = TRUE, .crypto = f$ops)),
+ "already has a crypto store")
+
+# A different app is a different store, so on the same device it
+# conflicts too. In production it does not arise: cornelius and tiny are
+# different user_ids, hence different devices.
+f <- fake_crypto()
+chat_poll(seam_client(e2ee = TRUE, app = "cornelius", .crypto = f$ops))
+expect_error(chat_poll(seam_client(e2ee = TRUE, app = "tiny",
+ .crypto = f$ops)),
+ "already has a crypto store")
+
+# Forgetting an identity is how to re-home one deliberately, and it
+# leaves the other identities interned.
+f <- fake_crypto()
+one <- fake_mx(user_id = "@one:ex")
+two <- fake_mx(user_id = "@two:ex")
+chat_poll(seam_client(e2ee = TRUE, .crypto = f$ops, mx = one))
+chat_poll(seam_client(e2ee = TRUE, .crypto = f$ops, mx = two))
+chat.api:::matrix_crypto_forget(chat.api:::matrix_crypto_cache_key(two))
+chat_poll(seam_client(e2ee = TRUE, .crypto = f$ops, mx = one))
+chat_poll(seam_client(e2ee = TRUE, .crypto = f$ops, mx = two))
+expect_identical(length(f$log$init), 3L)
+expect_identical(f$log$init[[3L]]$mx$user_id, "@two:ex")
+# ... and after forgetting, a new store for that device is allowed.
+chat.api:::matrix_crypto_forget(chat.api:::matrix_crypto_cache_key(one))
+chat_send(seam_client(e2ee = TRUE, crypto_store = "/tmp/rehomed",
+ .crypto = f$ops, mx = one), "!enc:ex", "x")
+expect_identical(f$log$init[[length(f$log$init)]]$store, "/tmp/rehomed")
+
+# ---- Encrypted sends ----
+
+# An encrypted room takes the Megolm path and the cleartext send never
+# runs. This is the assertion that matters most: a regression here PUTs
+# plaintext into a room the user believes is encrypted.
+f <- fake_crypto()
+cleartext <- new.env(parent = emptyenv()); cleartext$n <- 0L
+enc <- seam_client(e2ee = TRUE, .crypto = f$ops,
+ send = function(...) { cleartext$n <- cleartext$n + 1L
+ "$clear1" })
+id <- chat_send(enc, "!enc:ex", "secret")
+expect_identical(id, "$enc1")
+expect_identical(cleartext$n, 0L)
+expect_identical(length(f$log$sent), 1L)
+expect_identical(f$log$sent[[1L]]$text, "secret")
+expect_identical(f$log$sent[[1L]]$room_id, "!enc:ex")
+
+# A cleartext room on the same client still goes out in the clear: e2ee
+# is a per-room property, not a per-client one.
+id <- chat_send(enc, "!plain:ex", "public")
+expect_identical(id, "$clear1")
+expect_identical(cleartext$n, 1L)
+expect_identical(length(f$log$sent), 1L)
+
+# The room is asked about on every send rather than once at init, so a
+# room that turns on encryption between polls does not get one cleartext
+# message first.
+expect_identical(f$log$asked, c("!enc:ex", "!plain:ex"))
+f$ctx$encrypted <- c(f$ctx$encrypted, "!plain:ex")
+expect_identical(chat_send(enc, "!plain:ex", "now secret"), "$enc1")
+expect_identical(cleartext$n, 1L)
+
+# ---- An unanswerable encryption state is not a plaintext room ----
+# The lookup used to turn every failure -- expired token, timeout, 500 --
+# into FALSE, and the send went out in the clear. On an e2ee client that
+# is a leak, so it aborts instead.
+f <- fake_crypto(lookup_error = TRUE)
+cleartext <- new.env(parent = emptyenv()); cleartext$n <- 0L
+enc <- seam_client(e2ee = TRUE, .crypto = f$ops,
+ send = function(...) { cleartext$n <- cleartext$n + 1L
+ "$clear1" })
+expect_error(chat_send(enc, "!unknown:ex", "secret"),
+ "cannot determine the encryption state")
+expect_identical(cleartext$n, 0L)
+expect_identical(length(f$log$sent), 0L)
+# A cleartext client is untouched: with no crypto context there is no
+# lookup to fail, and the send goes out as it always did.
+plain <- seam_client(.crypto = f$ops,
+ send = function(...) { cleartext$n <- cleartext$n + 1L
+ "$clear1" })
+expect_identical(chat_send(plain, "!unknown:ex", "hi"), "$clear1")
+expect_identical(cleartext$n, 1L)
+
+# ---- Attachments are refused in encrypted rooms ----
+# mx_send_media() posts an ordinary cleartext m.file event. The check used
+# to run after the upload loop, so the files reached the homeserver in the
+# clear and only the text took the Megolm path -- and an attachment-only
+# send never asked at all.
+f <- fake_crypto()
+uploads <- new.env(parent = emptyenv()); uploads$n <- 0L
+enc <- seam_client(e2ee = TRUE, .crypto = f$ops,
+ media = function(mx, file, ...) {
+ uploads$n <- uploads$n + 1L
+ paste0("$m", basename(file))
+ })
+expect_error(chat_send(enc, "!enc:ex", "see these", files = "/a.png"),
+ "cannot send attachments to the encrypted room")
+# Nothing was uploaded: the refusal comes before the loop, not after it.
+expect_identical(uploads$n, 0L)
+expect_identical(length(f$log$sent), 0L)
+# Attachment-only sends are refused on the same terms.
+expect_error(chat_send(enc, "!enc:ex", "", files = "/a.png"),
+ "cannot send attachments")
+expect_identical(uploads$n, 0L)
+# A plaintext room on the same client still takes attachments.
+ids <- chat_send(enc, "!plain:ex", "see these", files = c("/a.png", "/b.png"))
+expect_identical(ids, c("$ma.png", "$mb.png", "$id"))
+expect_identical(uploads$n, 2L)
+
+# markup, kind, and mentions carry into the encrypted branch. Dropping
+# them would make an encrypted room silently lose formatting and pings
+# that the same call gets in the clear.
+f <- fake_crypto()
+enc <- seam_client(e2ee = TRUE, .crypto = f$ops)
+chat_send(enc, "!enc:ex", "**bold**", markup = "markdown",
+ mentions = "@carol:ex", kind = "notice")
+expect_true(f$log$sent[[1L]]$markdown)
+expect_identical(f$log$sent[[1L]]$mentions, "@carol:ex")
+expect_identical(f$log$sent[[1L]]$msgtype, "m.notice")
+chat_send(enc, "!enc:ex", "plain words")
+expect_false(f$log$sent[[2L]]$markdown)
+expect_null(f$log$sent[[2L]]$mentions)
+expect_identical(f$log$sent[[2L]]$msgtype, "m.text")
+
+# The encrypted send takes the client's live mx, not a copy captured at
+# init. A relogin replaces the token mid-poll, and a cached config would
+# spend the rest of the run authenticating with the rejected one.
+enc$env$mx <- fake_mx(sync_token = "rotated")
+chat_send(enc, "!enc:ex", "after relogin")
+expect_identical(f$log$sent[[3L]]$mx$sync_token, "rotated")
+
+# A failed encrypted send propagates, the same way the cleartext path's
+# failures do. Reporting no event id would have a caller record the
+# message as sent-but-unacknowledged rather than as failed.
+f <- fake_crypto()
+enc <- seam_client(e2ee = TRUE, .crypto = f$ops,
+ send = function(...) stop("must not be reached"))
+enc$crypto_ops$send <- function(...) stop("olm session could not be opened")
+expect_error(chat_send(enc, "!enc:ex", "secret"),
+ "olm session could not be opened")
+
+# ---- Decryption on poll ----
+
+dec_rec <- function(event_id, body = "psst", sender = "@alice:ex",
+ room_id = "!enc:ex", msgtype = "m.text",
+ sender_verified = TRUE, is_self = FALSE, ts = NULL,
+ mentions = NULL) {
+ list(room_id = room_id, event_id = event_id, sender = sender,
+ body = body, msgtype = msgtype, sender_verified = sender_verified,
+ is_self = is_self, ts = ts, mentions = mentions)
+}
+
+# Decrypted events come out as ordinary chat_message records, folded in
+# beside the cleartext ones. corteza used to run its own decrypt off $raw
+# and concatenate the results itself.
+f <- fake_crypto(decrypted = list(dec_rec("$e1", ts = 1700000000000)))
+enc <- seam_client(recs = list(rec("$c1", body = "in the clear")),
+ e2ee = TRUE, .crypto = f$ops)
+res <- chat_poll(enc)
+expect_identical(length(res$messages), 2L)
+expect_identical(f$log$decrypted, 1L)
+expect_identical(res$messages[[1L]]$body, "in the clear")
+expect_false(res$messages[[1L]]$encrypted)
+# sender_verified is NULL on cleartext: the transport asserts the sender
+# and there is nothing to verify, which is not the same answer as FALSE.
+expect_null(res$messages[[1L]]$sender_verified)
+m <- res$messages[[2L]]
+expect_inherits(m, "chat_message")
+expect_identical(m$id, "$e1")
+expect_identical(m$channel, "!enc:ex")
+expect_identical(m$sender, "@alice:ex")
+expect_identical(m$body, "psst")
+expect_true(m$encrypted)
+expect_true(m$sender_verified)
+expect_identical(m$kind, "message")
+
+# ---- Mixed timelines keep the homeserver's order ----
+# Appending the decrypted events put every one of them after every
+# cleartext one, so an encrypted message followed by a plain reply came
+# back the other way round -- which reorders a room's commands against
+# the messages they act on.
+f <- fake_crypto(decrypted = list(dec_rec("$first", body = "encrypted 1st")))
+enc <- seam_client(recs = list(rec("$second", body = "clear 2nd")),
+ e2ee = TRUE, .crypto = f$ops,
+ sync = wrap_sync(list(ev("$first"), ev("$second"))))
+res <- chat_poll(enc)
+expect_identical(vapply(res$messages, function(m) m$id, character(1)),
+ c("$first", "$second"))
+expect_true(res$messages[[1L]]$encrypted)
+expect_false(res$messages[[2L]]$encrypted)
+
+# Interleaved, three deep, to catch an ordering that only looks right
+# when the encrypted event happens to come first.
+f <- fake_crypto(decrypted = list(dec_rec("$b"), dec_rec("$d")))
+enc <- seam_client(recs = list(rec("$a"), rec("$c"), rec("$e")),
+ e2ee = TRUE, .crypto = f$ops,
+ sync = wrap_sync(list(ev("$a"), ev("$b"), ev("$c"),
+ ev("$d"), ev("$e"))))
+expect_identical(vapply(chat_poll(enc)$messages, function(m) m$id,
+ character(1)),
+ c("$a", "$b", "$c", "$d", "$e"))
+
+# An event the sync did not position keeps its arrival order at the end
+# rather than being dropped.
+f <- fake_crypto(decrypted = list(dec_rec("$nowhere")))
+enc <- seam_client(recs = list(rec("$a")), e2ee = TRUE, .crypto = f$ops,
+ sync = wrap_sync(list(ev("$a"))))
+expect_identical(vapply(chat_poll(enc)$messages, function(m) m$id,
+ character(1)),
+ c("$a", "$nowhere"))
+
+# An unverified sender is FALSE, not NULL: the payload decrypted, but the
+# claimed sender is the homeserver's word rather than a verified device.
+f <- fake_crypto(decrypted = list(dec_rec("$e1", sender_verified = FALSE)))
+res <- chat_poll(seam_client(e2ee = TRUE, .crypto = f$ops))
+expect_false(res$messages[[1L]]$sender_verified)
+# A missing flag is also FALSE. Absence of proof is not proof.
+f <- fake_crypto(decrypted = list(dec_rec("$e1", sender_verified = NULL)))
+res <- chat_poll(seam_client(e2ee = TRUE, .crypto = f$ops))
+expect_false(res$messages[[1L]]$sender_verified)
+
+# Decrypted msgtypes map into the contract's kind vocabulary, same as
+# cleartext, so a cross-adapter kind filter treats both alike.
+f <- fake_crypto(decrypted = list(dec_rec("$e1", msgtype = "m.notice"),
+ dec_rec("$e2", msgtype = "m.emote")))
+res <- chat_poll(seam_client(e2ee = TRUE, .crypto = f$ops))
+expect_identical(res$messages[[1L]]$kind, "notice")
+expect_identical(res$messages[[2L]]$kind, "emote")
+
+# A decrypted record without its own ts takes the timestamp off the sync,
+# and an unknown time stays NA rather than becoming the poll's clock.
+f <- fake_crypto(decrypted = list(dec_rec("$e1")))
+res <- chat_poll(seam_client(e2ee = TRUE, .crypto = f$ops,
+ sync = wrap_sync(list(ev("$e1", ts = 1700000000000)))))
+expect_false(is.na(res$messages[[1L]]$ts))
+f <- fake_crypto(decrypted = list(dec_rec("$nowhere")))
+res <- chat_poll(seam_client(e2ee = TRUE, .crypto = f$ops))
+expect_true(is.na(res$messages[[1L]]$ts))
+
+# The bot's own encrypted echo is marked self, so a consumer replying to
+# inbound mail does not answer itself.
+f <- fake_crypto(decrypted = list(dec_rec("$e1", is_self = TRUE)))
+res <- chat_poll(seam_client(e2ee = TRUE, .crypto = f$ops))
+expect_true(res$messages[[1L]]$self)
+
+# ---- A sync is not consumed until its crypto state is on disk ----
+# mx_sync_update(save = TRUE) commits the advanced cursor before anything
+# parses the response, which is what makes a malformed event survivable.
+# But a sync carrying a room key is only consumed once that key is
+# persisted: the homeserver never re-sends it, so a crash in between
+# leaves every later message in that room undecryptable.
+
+saves <- new.env(parent = emptyenv())
+saves$order <- character()
+saved_client <- function(f, ...) {
+ seam_client(e2ee = TRUE, .crypto = f$ops,
+ save = function(client, app = NULL, ...) {
+ saves$order <- c(saves$order, "cursor")
+ client
+ }, ...)
+}
+
+# The sync does not write the cursor itself...
+f <- fake_crypto()
+f$ops$decrypt <- function(crypto, sync, mx) {
+ saves$order <- c(saves$order, "crypto")
+ list()
+}
+trace <- new.env(); trace$sync <- list(); trace$extract <- list()
+saves$order <- character()
+chat_poll(saved_client(f, record = trace))
+expect_false(trace$sync[[1L]]$save)
+# ... it is written afterwards, and after the crypto state.
+expect_identical(saves$order, c("crypto", "cursor"))
+
+# A crypto failure means the sync was not consumed: no cursor write, and
+# the error is not swallowed. mx.client already skips an individual event
+# it has no session for, so a throw here is to-device processing or the
+# session save itself.
+f <- fake_crypto(decrypt_error = TRUE)
+saves$order <- character()
+expect_error(chat_poll(saved_client(f)), "no session for that megolm stream")
+expect_identical(saves$order, character())
+
+# ... and "not consumed" has to hold in memory too, not just on disk.
+# Leaving the advanced cursor on the client only moved the skip: a caller
+# that catches the error and polls the same client again would resume
+# past the sync whose room keys were lost, and the homeserver never
+# re-sends them.
+f <- fake_crypto(decrypt_error = TRUE)
+retry <- seam_client(e2ee = TRUE, .crypto = f$ops, token = "advanced",
+ mx = fake_mx(sync_token = "before"),
+ save = function(client, ...) client)
+expect_error(chat_poll(retry), "no session")
+expect_identical(retry$env$mx$sync_token, "before")
+# The next poll asks from where the failed one started.
+trace6 <- new.env(); trace6$sync <- list(); trace6$extract <- list()
+retry2 <- seam_client(e2ee = TRUE, .crypto = f$ops, token = "advanced",
+ mx = fake_mx(sync_token = "before"), record = trace6,
+ save = function(client, ...) client)
+expect_error(chat_poll(retry2), "no session")
+expect_error(chat_poll(retry2), "no session")
+expect_identical(trace6$sync[[2L]]$client$sync_token, "before")
+
+# A relogin's refreshed credentials survive the rollback: a rotated token
+# is not what makes a sync consumed, and dropping it would have the retry
+# authenticate with the one the homeserver just rejected.
+f <- fake_crypto(decrypt_error = TRUE)
+rl <- chat_matrix(mx = fake_mx(sync_token = "before"), e2ee = TRUE,
+ .crypto = f$ops,
+ .sync = function(client, ...) {
+ c2 <- fake_mx("advanced")
+ c2$token <- "rotated"
+ list(sync = wrap_sync(list()), client = c2,
+ first_run = FALSE)
+ },
+ .extract = function(...) list(),
+ .send = function(...) "$id", .media = function(...) NULL,
+ .save = function(client, ...) client)
+expect_error(chat_poll(rl), "no session")
+expect_identical(rl$env$mx$token, "rotated")
+expect_identical(rl$env$mx$sync_token, "before")
+
+# A successful poll does advance it, or nothing would ever move.
+f <- fake_crypto()
+ok <- seam_client(e2ee = TRUE, .crypto = f$ops, token = "advanced",
+ mx = fake_mx(sync_token = "before"),
+ save = function(client, ...) client)
+chat_poll(ok)
+expect_identical(ok$env$mx$sync_token, "advanced")
+
+# save_cursor = FALSE still means nothing is written, either way.
+f <- fake_crypto()
+saves$order <- character()
+chat_poll(saved_client(f, save_cursor = FALSE))
+expect_identical(saves$order, character())
+
+# A cleartext client keeps the cursor inside the sync, where the
+# poison-pill protection wants it. Only e2ee pays for durability.
+trace2 <- new.env(); trace2$sync <- list(); trace2$extract <- list()
+chat_poll(seam_client(record = trace2))
+expect_true(trace2$sync[[1L]]$save)
+
+# Nothing consults crypto when e2ee is off, so an installed mx.crypto
+# cannot change what a cleartext client does.
+f <- fake_crypto(decrypted = list(dec_rec("$e1")))
+res <- chat_poll(seam_client(recs = list(rec("$c1")), .crypto = f$ops))
+expect_identical(f$log$decrypted, 0L)
+expect_identical(length(res$messages), 1L)
+
+# ---- Encrypted-room cache file ----
+# One room id per line, not JSON: chat.api has no dependencies. A stale
+# encrypted_rooms.json from corteza is ignored, and losing the cache
+# costs nothing because init rescans the homeserver's own room state.
+store <- file.path(tempfile("chatapi-store"))
+dir.create(store)
+expect_identical(chat.api:::matrix_crypto_load_encrypted(store), character())
+ctx <- new.env(parent = emptyenv())
+ctx$store <- store
+ctx$encrypted <- c("!a:ex", "!b:ex")
+chat.api:::matrix_crypto_save_encrypted(ctx)
+expect_identical(chat.api:::matrix_crypto_load_encrypted(store),
+ c("!a:ex", "!b:ex"))
+# An empty set round trips as an empty set, not as one blank room id.
+ctx$encrypted <- character()
+chat.api:::matrix_crypto_save_encrypted(ctx)
+expect_identical(chat.api:::matrix_crypto_load_encrypted(store), character())
+# A leftover JSON file is not read as room ids.
+writeLines('["!json:ex"]', file.path(store, "encrypted_rooms.json"))
+expect_identical(chat.api:::matrix_crypto_load_encrypted(store), character())
+unlink(store, recursive = TRUE)
+# Saving into a store that does not exist yet creates it.
+store2 <- tempfile("chatapi-store2")
+ctx$store <- store2
+ctx$encrypted <- "!c:ex"
+chat.api:::matrix_crypto_save_encrypted(ctx)
+expect_true(dir.exists(store2))
+expect_identical(chat.api:::matrix_crypto_load_encrypted(store2), "!c:ex")
+unlink(store2, recursive = TRUE)
+
+# The ops table defaults to the real implementations and overrides
+# selectively, so a seam that names one operation does not blank the rest.
+ops <- chat.api:::matrix_crypto_ops()
+expect_identical(sort(names(ops)), c("decrypt", "encrypted", "init", "send"))
+expect_identical(ops$send, chat.api:::matrix_crypto_send)
+
+# ---- The store is bound to the device that owns it ----
+# The directory name is sanitized and therefore not injective: "@a/b:ex"
+# and "@a_b:ex" land in the same place. The exact identity is written
+# into the store and compared on every open, so a collision -- or a
+# copied store, or a changed device_id -- is an error rather than one bot
+# silently opening another's Olm account.
+store <- tempfile("chatapi-id")
+chat.api:::matrix_crypto_bind_identity(store, fake_mx(user_id = "@a:ex",
+ device_id = "D1"))
+expect_true(file.exists(file.path(store, "identity.txt")))
+expect_identical(readLines(file.path(store, "identity.txt")),
+ c("@a:ex", "D1"))
+# Reopening with the same identity is fine, and does not rewrite it.
+expect_silent(chat.api:::matrix_crypto_bind_identity(
+ store, fake_mx(user_id = "@a:ex", device_id = "D1")))
+# A different user, or the same user on a different device, is not.
+expect_error(chat.api:::matrix_crypto_bind_identity(
+ store, fake_mx(user_id = "@b:ex", device_id = "D1")),
+ "belongs to")
+expect_error(chat.api:::matrix_crypto_bind_identity(
+ store, fake_mx(user_id = "@a:ex", device_id = "D2")),
+ "belongs to")
+# The collision the sanitization allows is caught by the same check.
+# (That the two identities really do share a directory is asserted in
+# test_matrix_mxclient.R: resolving the path calls into mx.client, and
+# nothing in this file may.)
+shared <- tempfile("chatapi-collide")
+chat.api:::matrix_crypto_bind_identity(shared, fake_mx(user_id = "@a/b:ex",
+ device_id = "D"))
+expect_error(chat.api:::matrix_crypto_bind_identity(
+ shared, fake_mx(user_id = "@a_b:ex", device_id = "D")), "belongs to")
+unlink(c(store, shared), recursive = TRUE)
+
+# Both halves of the identity are required, wherever it is asked for.
+expect_error(chat.api:::matrix_crypto_identity(list(user_id = "@a:ex")),
+ "user_id and device_id")
+expect_error(chat.api:::matrix_crypto_identity(list(device_id = "D")),
+ "user_id and device_id")
+expect_error(chat.api:::matrix_crypto_identity(
+ list(user_id = "@a:ex", device_id = "")), "user_id and device_id")
+expect_identical(
+ chat.api:::matrix_crypto_identity(list(user_id = "@a:ex",
+ device_id = "D"))[["device_id"]],
+ "D")
+
+# ---- The device query covers to-device senders ----
+# sender_bound is stamped once, when a room key arrives over to-device,
+# and persisted with the Megolm session; a later sync carrying a timeline
+# message from that sender does not rebind it. Room keys normally arrive
+# before the message they are for, so asking only about timeline senders
+# recorded them unverified forever.
+senders <- chat.api:::matrix_encrypted_senders
+td <- function(events) list(to_device = list(events = events))
+expect_identical(
+ senders(td(list(list(type = "m.room.encrypted", sender = "@a:ex")))),
+ "@a:ex")
+# Timeline and to-device are unioned, and each sender appears once.
+sync <- c(td(list(list(type = "m.room.encrypted", sender = "@a:ex"),
+ list(type = "m.room.encrypted", sender = "@b:ex"))),
+ list(rooms = list(join = list(`!r:ex` = list(timeline = list(
+ events = list(list(type = "m.room.encrypted",
+ sender = "@a:ex"))))))))
+expect_identical(sort(senders(sync)), c("@a:ex", "@b:ex"))
+# Other to-device types are not senders to ask about.
+expect_identical(
+ senders(td(list(list(type = "m.room_key_request", sender = "@a:ex")))),
+ character())
+expect_identical(senders(list()), character())
+
+# ---- Timeline order ----
+pos <- chat.api:::matrix_event_order
+expect_identical(pos(wrap_sync(list(ev("$a"), ev("$b"))))[["$b"]], 2L)
+expect_identical(length(pos(list())), 0L)
+# Positions run across rooms, so the walk is the sync's own order rather
+# than a per-room one that would interleave arbitrarily.
+two <- list(rooms = list(join = list(
+ `!r1:ex` = list(timeline = list(events = list(ev("$a")))),
+ `!r2:ex` = list(timeline = list(events = list(ev("$b")))))))
+expect_identical(pos(two)[["$a"]], 1L)
+expect_identical(pos(two)[["$b"]], 2L)
+
+# ---- The four seams still run without mx.client ----
+# chat_matrix() documents mx plus .sync/.extract/.send/.media as enough
+# to poll and send on a host with no mx.client. That works because `%||%`
+# is lazy: the mx.client:: default on each of those four is never forced
+# when a seam is supplied. Adding a fifth default that was resolved
+# unconditionally broke it for every client, cleartext ones included, and
+# turned both CI legs red with "there is no package called 'mx.client'".
+#
+# The proof has to be that no mx.client symbol is referenced eagerly, and
+# an installed mx.client makes that invisible here -- so this reads the
+# constructor's own body instead of relying on the environment.
+ctor <- deparse(body(chat_matrix))
+defaults <- grep("mx\\.client::", ctor, value = TRUE)
+# Every remaining mx.client:: reference in the returned list is behind a
+# `%||%` whose left side is a seam.
+listed <- grep("_fn = ", defaults, value = TRUE)
+expect_true(all(grepl("%\\|\\|%", listed)))
+expect_false(any(grepl("save_fn = .*mx\\.client::", ctor)))
+
+# And by construction: a client built the documented way never touches
+# save_fn's default, because only the deferred-save path resolves one.
+seamed <- chat_matrix(mx = fake_mx(), .sync = function(...) NULL,
+ .extract = function(...) list(),
+ .send = function(...) "$id",
+ .media = function(...) NULL)
+expect_null(seamed$save_fn)
+expect_false(seamed$e2ee)
+# A supplied .save is kept verbatim rather than being wrapped.
+mine <- function(client, ...) client
+expect_identical(chat_matrix(mx = fake_mx(), .sync = function(...) NULL,
+ .extract = function(...) list(),
+ .send = function(...) "$id",
+ .media = function(...) NULL,
+ .save = mine)$save_fn, mine)
diff --git a/inst/tinytest/test_matrix_mxclient.R b/inst/tinytest/test_matrix_mxclient.R
index 33f408c..8eda688 100644
--- a/inst/tinytest/test_matrix_mxclient.R
+++ b/inst/tinytest/test_matrix_mxclient.R
@@ -263,3 +263,475 @@ boom <- chat_matrix(mx = list(user_id = "@bot:ex",
.send = fake_send, .media = fake_media,
.typing = function(...) stop("homeserver down"))
expect_false(chat_typing(boom, "!room:ex"))
+
+# ---- The real crypto boundary functions ----
+# These sit behind chat_matrix()'s .crypto seam, so the adapter-routing
+# tests in test_matrix.R never reach them. Their failure modes are the
+# two that leak, so they are driven directly here against stubbed
+# mx.client / mx.api entry points.
+
+crypto_ctx <- function(encrypted = character(), store = tempfile("cs")) {
+ ctx <- new.env(parent = emptyenv())
+ ctx$encrypted <- encrypted
+ ctx$store <- store
+ ctx$account <- NULL
+ ctx$sessions <- list()
+ ctx
+}
+
+stub <- function(name, value, ns) {
+ orig <- get(name, envir = asNamespace(ns))
+ assignInNamespace(name, value, ns = ns)
+ orig
+}
+
+mx_cfg <- list(user_id = "@bot:ex", device_id = "DEV",
+ server = "https://ex.invalid", token = "tok")
+
+# An unanswerable encryption state aborts rather than answering FALSE.
+# Turning an expired token, a timeout, or a 500 into "not encrypted" sent
+# the message in the clear.
+local({
+ orig <- stub("mx_room_encrypted",
+ function(...) stop("Matrix error [M_UNKNOWN_TOKEN]"),
+ "mx.client")
+ on.exit(assignInNamespace("mx_room_encrypted", orig, ns = "mx.client"))
+ expect_error(
+ chat.api:::matrix_room_is_encrypted(crypto_ctx(), mx_cfg, "!r:ex"),
+ "cannot determine the encryption state")
+})
+
+# A room that answers TRUE is remembered, so the next send skips the
+# lookup -- which is also what keeps a transient failure from blocking a
+# room already known to be encrypted.
+local({
+ ctx <- crypto_ctx()
+ orig <- stub("mx_room_encrypted", function(...) TRUE, "mx.client")
+ on.exit(assignInNamespace("mx_room_encrypted", orig, ns = "mx.client"))
+ expect_true(chat.api:::matrix_room_is_encrypted(ctx, mx_cfg, "!r:ex"))
+ expect_true("!r:ex" %in% ctx$encrypted)
+})
+local({
+ ctx <- crypto_ctx(encrypted = "!r:ex")
+ orig <- stub("mx_room_encrypted", function(...) stop("down"), "mx.client")
+ on.exit(assignInNamespace("mx_room_encrypted", orig, ns = "mx.client"))
+ expect_true(chat.api:::matrix_room_is_encrypted(ctx, mx_cfg, "!r:ex"))
+})
+
+# A room that answers FALSE is a plaintext room, and is not cached: the
+# next send asks again, so a room that turns encryption on is caught.
+local({
+ asked <- 0L
+ ctx <- crypto_ctx()
+ orig <- stub("mx_room_encrypted",
+ function(...) { asked <<- asked + 1L; FALSE }, "mx.client")
+ on.exit(assignInNamespace("mx_room_encrypted", orig, ns = "mx.client"))
+ expect_false(chat.api:::matrix_room_is_encrypted(ctx, mx_cfg, "!r:ex"))
+ expect_false(chat.api:::matrix_room_is_encrypted(ctx, mx_cfg, "!r:ex"))
+ expect_identical(asked, 2L)
+ expect_identical(ctx$encrypted, character())
+})
+
+# No crypto context is not an encrypted room, and asks nobody.
+local({
+ orig <- stub("mx_room_encrypted", function(...) stop("must not be asked"),
+ "mx.client")
+ on.exit(assignInNamespace("mx_room_encrypted", orig, ns = "mx.client"))
+ expect_false(chat.api:::matrix_room_is_encrypted(NULL, mx_cfg, "!r:ex"))
+})
+
+# Membership discovery is not best-effort. mx_send_encrypted() derives
+# the recipient devices from member_ids: an empty list shares the room key
+# with nobody, posts the event anyway, and returns an event id, so the
+# room cannot read the message while the caller records a successful send.
+local({
+ sent <- 0L
+ o1 <- stub("mx_client_session", function(...) list(server = "s"),
+ "mx.client")
+ o2 <- stub("mx_room_members", function(...) stop("500 from homeserver"),
+ "mx.api")
+ o3 <- stub("mx_send_encrypted",
+ function(...) { sent <<- sent + 1L
+ list(event_id = "$e", sessions = list()) },
+ "mx.client")
+ on.exit({
+ assignInNamespace("mx_client_session", o1, ns = "mx.client")
+ assignInNamespace("mx_room_members", o2, ns = "mx.api")
+ assignInNamespace("mx_send_encrypted", o3, ns = "mx.client")
+ })
+ expect_error(chat.api:::matrix_crypto_send(crypto_ctx(), mx_cfg, "!r:ex",
+ "secret"),
+ "500 from homeserver")
+ expect_identical(sent, 0L)
+})
+
+# An empty member list is the same refusal, reached without an error to
+# propagate.
+local({
+ sent <- 0L
+ o1 <- stub("mx_client_session", function(...) list(server = "s"),
+ "mx.client")
+ o2 <- stub("mx_room_members", function(...) character(), "mx.api")
+ o3 <- stub("mx_send_encrypted",
+ function(...) { sent <<- sent + 1L
+ list(event_id = "$e", sessions = list()) },
+ "mx.client")
+ on.exit({
+ assignInNamespace("mx_client_session", o1, ns = "mx.client")
+ assignInNamespace("mx_room_members", o2, ns = "mx.api")
+ assignInNamespace("mx_send_encrypted", o3, ns = "mx.client")
+ })
+ expect_error(chat.api:::matrix_crypto_send(crypto_ctx(), mx_cfg, "!r:ex",
+ "secret"),
+ "reach nobody")
+ expect_identical(sent, 0L)
+})
+
+# A session that cannot be built is the same: no send.
+local({
+ sent <- 0L
+ o1 <- stub("mx_client_session", function(...) stop("no server in config"),
+ "mx.client")
+ o3 <- stub("mx_send_encrypted",
+ function(...) { sent <<- sent + 1L
+ list(event_id = "$e", sessions = list()) },
+ "mx.client")
+ on.exit({
+ assignInNamespace("mx_client_session", o1, ns = "mx.client")
+ assignInNamespace("mx_send_encrypted", o3, ns = "mx.client")
+ })
+ expect_error(chat.api:::matrix_crypto_send(crypto_ctx(), mx_cfg, "!r:ex",
+ "secret"),
+ "no server in config")
+ expect_identical(sent, 0L)
+})
+
+# With members, the send goes through and carries them, plus the content
+# the cleartext path would have PUT.
+local({
+ seen <- NULL
+ o1 <- stub("mx_client_session", function(...) list(server = "s"),
+ "mx.client")
+ o2 <- stub("mx_room_members", function(...) c("@a:ex", "@b:ex"), "mx.api")
+ o3 <- stub("mx_send_encrypted",
+ function(client, account, sessions, room_id, content, store_dir,
+ recipients = NULL, member_ids = NULL) {
+ seen <<- list(content = content, member_ids = member_ids,
+ room_id = room_id)
+ list(event_id = "$enc", sessions = "new")
+ }, "mx.client")
+ on.exit({
+ assignInNamespace("mx_client_session", o1, ns = "mx.client")
+ assignInNamespace("mx_room_members", o2, ns = "mx.api")
+ assignInNamespace("mx_send_encrypted", o3, ns = "mx.client")
+ })
+ ctx <- crypto_ctx()
+ expect_identical(chat.api:::matrix_crypto_send(ctx, mx_cfg, "!r:ex",
+ "**hi**",
+ markdown = TRUE), "$enc")
+ expect_identical(seen$member_ids, c("@a:ex", "@b:ex"))
+ expect_identical(seen$content$body, "**hi**")
+ expect_identical(seen$content$format, "org.matrix.custom.html")
+ expect_true(grepl("hi", seen$content$formatted_body))
+ # The updated session set is kept, or the next send re-shares the key.
+ expect_identical(ctx$sessions, "new")
+})
+
+# The content an encrypted send wraps is the one mx_send_text() PUTs in
+# the clear, so a room's encryption state changes the envelope and
+# nothing a reader sees.
+content <- chat.api:::matrix_crypto_content
+expect_identical(content("hi")$body, "hi")
+expect_null(content("hi")$formatted_body)
+expect_identical(content("hi", msgtype = "m.notice")$msgtype, "m.notice")
+expect_true(grepl("hi",
+ content("**hi**", markdown = TRUE)$formatted_body))
+m <- content("hi", mentions = "@c:ex")
+expect_identical(m[["m.mentions"]]$user_ids, list("@c:ex"))
+expect_identical(m$format, "org.matrix.custom.html")
+
+# matrix_crypto_init() binds the store before it opens the account, so a
+# store belonging to another device is refused rather than unpickled.
+# Every mx.client entry point below is stubbed: what is under test is the
+# order of the adapter's own steps, not the cryptography.
+if (requireNamespace("mx.crypto", quietly = TRUE)) {
+ local({
+ opened <- 0L
+ o1 <- stub("mx_crypto_account",
+ function(...) { opened <<- opened + 1L; "acct" },
+ "mx.client")
+ o2 <- stub("mx_crypto_publish_keys", function(...) invisible(TRUE),
+ "mx.client")
+ o3 <- stub("mx_crypto_sessions_load", function(...) list(),
+ "mx.client")
+ o4 <- stub("mx_client_session", function(...) list(server = "s"),
+ "mx.client")
+ o5 <- stub("mxc_account_identity_keys",
+ function(...) list(curve25519 = "C", ed25519 = "E"),
+ "mx.crypto")
+ # A homeserver that has never seen this device, and no rooms. The
+ # published-key check is exercised on its own further down.
+ o6 <- stub("mx_keys_query", function(...) list(device_keys = list()),
+ "mx.api")
+ o7 <- stub("mx_rooms", function(...) character(), "mx.api")
+ on.exit({
+ assignInNamespace("mx_crypto_account", o1, ns = "mx.client")
+ assignInNamespace("mx_crypto_publish_keys", o2, ns = "mx.client")
+ assignInNamespace("mx_crypto_sessions_load", o3, ns = "mx.client")
+ assignInNamespace("mx_client_session", o4, ns = "mx.client")
+ assignInNamespace("mxc_account_identity_keys", o5, ns = "mx.crypto")
+ assignInNamespace("mx_keys_query", o6, ns = "mx.api")
+ assignInNamespace("mx_rooms", o7, ns = "mx.api")
+ })
+
+ store <- tempfile("initstore")
+ a <- list(user_id = "@a:ex", device_id = "D1", token = "t",
+ server = "https://ex.invalid")
+ ctx <- chat.api:::matrix_crypto_init(a, store = store)
+ expect_identical(ctx$store, store)
+ expect_identical(opened, 1L)
+ expect_identical(readLines(file.path(store, "identity.txt")),
+ c("@a:ex", "D1"))
+
+ # Another device pointed at the same store is refused, and the
+ # account is never opened for it.
+ b <- list(user_id = "@b:ex", device_id = "D2", token = "t",
+ server = "https://ex.invalid")
+ expect_error(chat.api:::matrix_crypto_init(b, store = store),
+ "belongs to")
+ expect_identical(opened, 1L)
+ unlink(store, recursive = TRUE)
+ })
+}
+
+# The store path is per device, and its sanitization is not injective:
+# "@a/b:ex" and "@a_b:ex" resolve to the same directory. That is why the
+# identity is written into the store and compared on open -- the
+# collision is caught there, in test_matrix.R. Resolving the path calls
+# mx_crypto_store_dir(), which is why this half lives here.
+local({
+ a <- list(user_id = "@a/b:ex", device_id = "D")
+ b <- list(user_id = "@a_b:ex", device_id = "D")
+ expect_identical(chat.api:::matrix_crypto_store(a, app = "t"),
+ chat.api:::matrix_crypto_store(b, app = "t"))
+ # Two devices of one user are two stores, and two users are two too.
+ one <- list(user_id = "@a:ex", device_id = "D1")
+ two <- list(user_id = "@a:ex", device_id = "D2")
+ expect_false(identical(chat.api:::matrix_crypto_store(one, app = "t"),
+ chat.api:::matrix_crypto_store(two, app = "t")))
+ expect_false(identical(chat.api:::matrix_crypto_store(one, app = "t"),
+ chat.api:::matrix_crypto_store(
+ list(user_id = "@b:ex", device_id = "D1"),
+ app = "t")))
+ # It sits under mx.client's own store convention for the app.
+ expect_true(startsWith(chat.api:::matrix_crypto_store(one, app = "t"),
+ mx.client::mx_crypto_store_dir(app = "t")))
+})
+
+# ---- One identity per device, durably ----
+# The in-process cache stops two contexts existing at once and nothing
+# more: restart, or matrix_crypto_forget(), and a changed crypto_store
+# would mint a fresh account and publish different long-lived keys under
+# the old device_id. The homeserver is the authority on which keys a
+# device has, so that is what init checks against.
+#
+# The raw /keys/query response is stubbed rather than
+# mx_crypto_known_devices(), because that helper drops entries whose
+# signatures fail and this check has to tell "no entry" from "an entry
+# that will not verify".
+if (requireNamespace("mx.crypto", quietly = TRUE)) {
+ init_stubs <- function(query, verify = NULL) {
+ list(
+ mx_crypto_account = stub("mx_crypto_account", function(...) "acct",
+ "mx.client"),
+ mx_crypto_publish_keys = stub("mx_crypto_publish_keys",
+ function(...) invisible(TRUE),
+ "mx.client"),
+ mx_crypto_sessions_load = stub("mx_crypto_sessions_load",
+ function(...) list(), "mx.client"),
+ mx_client_session = stub("mx_client_session",
+ function(...) list(server = "s"),
+ "mx.client"),
+ mx_keys_query = stub("mx_keys_query", query, "mx.api"),
+ # Spelled out rather than `verify %||% default`: base R has
+ # no %||% before 4.4, and this package supports R 4.0.
+ mxc_verify_device_keys = stub(
+ "mxc_verify_device_keys",
+ if (is.null(verify)) {
+ function(...) list(curve25519 = "CURVE-ours",
+ ed25519 = "ED-ours")
+ } else {
+ verify
+ },
+ "mx.crypto"),
+ mxc_account_identity_keys = stub(
+ "mxc_account_identity_keys",
+ function(...) list(curve25519 = "CURVE-ours",
+ ed25519 = "ED-ours"), "mx.crypto"))
+ }
+ restore_stubs <- function(o) {
+ ns_of <- function(nm) {
+ if (startsWith(nm, "mxc_")) "mx.crypto"
+ else if (nm == "mx_keys_query") "mx.api"
+ else "mx.client"
+ }
+ for (nm in names(o)) assignInNamespace(nm, o[[nm]], ns = ns_of(nm))
+ }
+ me <- list(user_id = "@a:ex", device_id = "D1", token = "t",
+ server = "https://ex.invalid")
+ entry_for <- function(...) {
+ list(device_keys = list(`@a:ex` = list(...)))
+ }
+ with_init <- function(stubs, expr) {
+ o <- stubs
+ on.exit(restore_stubs(o))
+ force(expr)
+ }
+
+ # A device the homeserver has never seen is the first run.
+ local({
+ asked <- NULL
+ o <- init_stubs(function(session, device_keys, ...) {
+ asked <<- names(device_keys)
+ entry_for()
+ })
+ on.exit(restore_stubs(o))
+ store <- tempfile("pub1")
+ expect_true(is.environment(chat.api:::matrix_crypto_init(me,
+ store = store)))
+ # It asks about its own user, not the room's members.
+ expect_identical(asked, "@a:ex")
+ unlink(store, recursive = TRUE)
+ })
+
+ # Keys that match are this device's own account: proceed.
+ local({
+ o <- init_stubs(function(...) entry_for(D1 = list(keys = "whatever")))
+ on.exit(restore_stubs(o))
+ store <- tempfile("pub2")
+ expect_true(is.environment(chat.api:::matrix_crypto_init(me,
+ store = store)))
+ unlink(store, recursive = TRUE)
+ })
+
+ # Keys that differ mean this store is not this device's. This is the
+ # case a restart plus a changed crypto_store produced, which nothing
+ # in the process cache could have seen.
+ local({
+ o <- init_stubs(
+ function(...) entry_for(D1 = list(keys = "whatever")),
+ verify = function(...) list(curve25519 = "CURVE-theirs",
+ ed25519 = "ED-theirs"))
+ on.exit(restore_stubs(o))
+ store <- tempfile("pub3")
+ expect_error(chat.api:::matrix_crypto_init(me, store = store),
+ "already has different device keys")
+ unlink(store, recursive = TRUE)
+ })
+
+ # An entry that will not verify is NOT an absent entry. A tampered or
+ # signature-stripped record for our own device used to be dropped by
+ # mx_crypto_known_devices() with a warning, come back as "no device",
+ # and get published over.
+ local({
+ o <- init_stubs(
+ function(...) entry_for(D1 = list(keys = "tampered")),
+ verify = function(...) stop("signature does not verify"))
+ on.exit(restore_stubs(o))
+ store <- tempfile("pub4")
+ expect_error(chat.api:::matrix_crypto_init(me, store = store),
+ "do not verify")
+ unlink(store, recursive = TRUE)
+ })
+
+ # ... and it does not publish over one either.
+ local({
+ uploads <- 0L
+ o <- init_stubs(
+ function(...) entry_for(D1 = list(keys = "tampered")),
+ verify = function(...) stop("signature does not verify"))
+ assignInNamespace("mx_crypto_publish_keys",
+ function(...) { uploads <<- uploads + 1L
+ invisible(TRUE) }, ns = "mx.client")
+ on.exit(restore_stubs(o))
+ store <- tempfile("pub5")
+ expect_error(chat.api:::matrix_crypto_init(me, store = store))
+ expect_identical(uploads, 0L)
+ unlink(store, recursive = TRUE)
+ })
+
+ # Another device of the same user is not this device, and does not
+ # collide with it.
+ local({
+ o <- init_stubs(function(...) entry_for(D2 = list(keys = "other")))
+ on.exit(restore_stubs(o))
+ store <- tempfile("pub6")
+ expect_true(is.environment(chat.api:::matrix_crypto_init(me,
+ store = store)))
+ unlink(store, recursive = TRUE)
+ })
+
+ # A query that cannot be answered is an error, not a shrug. init is
+ # about to publish keys to that same homeserver, so being unable to
+ # ask it anything is not a state to publish from.
+ local({
+ o <- init_stubs(function(...) stop("504 from homeserver"))
+ on.exit(restore_stubs(o))
+ store <- tempfile("pub7")
+ expect_error(chat.api:::matrix_crypto_init(me, store = store),
+ "cannot read this device's published keys")
+ unlink(store, recursive = TRUE)
+ })
+
+ # /keys/query answers 200 with a `failures` map when it could not
+ # reach a server, and returns whatever it did manage. This query names
+ # one user -- ours -- so any failure means the question went
+ # unanswered, and the empty device_keys beside it is not evidence of a
+ # device that was never published. A request that throws and one that
+ # succeeds with nothing in it are the same state here.
+ local({
+ uploads <- 0L
+ o <- init_stubs(function(...) list(
+ failures = list(ex = list(errcode = "M_UNKNOWN",
+ error = "Failed to fetch keys")),
+ device_keys = list()))
+ assignInNamespace("mx_crypto_publish_keys",
+ function(...) { uploads <<- uploads + 1L
+ invisible(TRUE) }, ns = "mx.client")
+ on.exit(restore_stubs(o))
+ store <- tempfile("pub9")
+ expect_error(chat.api:::matrix_crypto_init(me, store = store),
+ "could not answer for ex")
+ expect_identical(uploads, 0L)
+ unlink(store, recursive = TRUE)
+ })
+
+ # An empty failures map is not a failure: that is the ordinary
+ # first-run answer and must not start erroring.
+ local({
+ o <- init_stubs(function(...) list(failures = list(),
+ device_keys = list()))
+ on.exit(restore_stubs(o))
+ store <- tempfile("pub10")
+ expect_true(is.environment(chat.api:::matrix_crypto_init(me,
+ store = store)))
+ unlink(store, recursive = TRUE)
+ })
+
+ # The check runs before the upload, so a mismatch never publishes.
+ local({
+ uploads <- 0L
+ o <- init_stubs(
+ function(...) entry_for(D1 = list(keys = "whatever")),
+ verify = function(...) list(curve25519 = "CURVE-theirs",
+ ed25519 = "ED-theirs"))
+ assignInNamespace("mx_crypto_publish_keys",
+ function(...) { uploads <<- uploads + 1L
+ invisible(TRUE) }, ns = "mx.client")
+ on.exit(restore_stubs(o))
+ store <- tempfile("pub8")
+ expect_error(chat.api:::matrix_crypto_init(me, store = store))
+ expect_identical(uploads, 0L)
+ unlink(store, recursive = TRUE)
+ })
+}
diff --git a/man/chat_matrix.Rd b/man/chat_matrix.Rd
index 08241d1..bcd6653 100644
--- a/man/chat_matrix.Rd
+++ b/man/chat_matrix.Rd
@@ -3,9 +3,22 @@
\alias{chat_matrix}
\title{Create a Matrix chat client}
\usage{
-chat_matrix(app = NULL, path = NULL, save_cursor = TRUE, mx = NULL,
- relogin = TRUE, .sync = NULL, .extract = NULL, .send = NULL,
- .media = NULL, .typing = NULL)
+chat_matrix(
+ app = NULL,
+ path = NULL,
+ save_cursor = TRUE,
+ mx = NULL,
+ relogin = TRUE,
+ e2ee = FALSE,
+ crypto_store = NULL,
+ .sync = NULL,
+ .extract = NULL,
+ .send = NULL,
+ .media = NULL,
+ .typing = NULL,
+ .crypto = NULL,
+ .save = NULL
+)
}
\arguments{
\item{app}{Application namespace passed to
@@ -37,6 +50,61 @@ refreshed config lands back in the client, so the next poll and any
send use the new token. FALSE lets \code{mx_error_M_UNKNOWN_TOKEN}
propagate.}
+\item{e2ee}{Logical. Own Olm/Megolm state in the adapter, so
+\code{\link{chat_send}} encrypts for rooms that advertise
+\code{m.room.encryption} and \code{\link{chat_poll}} decrypts
+inbound. FALSE (the default) is the previous behaviour exactly:
+cleartext only, no crypto state, and \code{e2ee} stays FALSE in
+\code{\link{chat_capabilities}}. Requires the 'mx.crypto' package,
+which needs a Rust toolchain, and both \code{user_id} and
+\code{device_id} on the wrapped config.
+
+Three things change when it is on. The crypto state is built on
+first use rather than here: from \code{\link{chat_poll}} that means
+key publication happens after the sync, so a relogin has already
+replaced a rejected token, while a \code{\link{chat_send}} that runs
+before any poll publishes with the token it has -- the same exposure
+any direct send already carries, since \code{mx_with_relogin()}
+wraps only the sync. The sync cursor is held back until the crypto
+state is safe, on disk and on the client, so a sync carrying a room
+key is not marked consumed until that key is stored. And attachments
+are refused in encrypted rooms, because Matrix media uploads are not
+encrypted by this adapter -- \code{\link{chat_capabilities}} reports
+\code{files = FALSE} to match.}
+
+\item{crypto_store}{Character or NULL. Directory holding the pickled
+Olm account and sessions. NULL derives it from \code{app} via
+\code{mx.client::mx_crypto_store_dir()}, with a per-device
+subdirectory, so moving a config does not cost the device identity
+and two bots under one app namespace do not share an Olm account.
+
+A Matrix device has one Olm identity for its whole life, and three
+things hold that. The store records the \code{(user_id, device_id)}
+it belongs to and refuses to open for a different one. Within a
+process, one device gets one context and one store, so pointing a
+second store at it is an error rather than a second account. And on
+first use the account's keys are checked against the ones the
+homeserver already has for that \code{device_id} -- the only one of
+the three that survives a restart, and so the one that catches a
+store swapped between runs. Log in again for a new \code{device_id}
+rather than re-homing an existing one.
+
+That last check distinguishes three cases: no published device is a
+first run, a published device whose keys match is this account, and
+a published device that either differs or fails signature
+verification is an error. A \code{/keys/query} that returns a
+\code{failures} map is an error too, since the empty result beside
+it is an unanswered question rather than an absent device. It cannot
+see a homeserver that omits the device deliberately, which is
+indistinguishable from a first run without key pinning or
+cross-signing.
+
+A Windows drive-relative \code{crypto_store} (\code{"C:store"}) is
+rejected. It names a directory relative to the current directory of
+that drive, which cannot be read here, so two such paths cannot be
+told apart and two stores could share one context. Pass an absolute
+path.}
+
\item{.sync}{Testing seam: replacement for
\code{mx.client::mx_sync_update}. Leave NULL in production.}
@@ -56,6 +124,19 @@ without mx.client installed; \code{chat_typing()} and
\code{mx.api::mx_typing}. Leave NULL in production. It is resolved
when \code{chat_typing()} is called, not here, so a NULL seam costs
nothing on installs without mx.api.}
+
+\item{.crypto}{Testing seam: a named list overriding any of the
+adapter's four crypto operations -- \code{init}, \code{encrypted},
+\code{send}, \code{decrypt}. Leave NULL in production. Supplying
+\code{init} is what lets \code{e2ee = TRUE} be tested on a runner
+with neither mx.crypto nor a Rust toolchain; E2EE is the one part of
+this adapter with no honest way to reach a real homeserver from a
+test.}
+
+\item{.save}{Testing seam: replacement for
+\code{mx.client::mx_client_save}. Leave NULL in production. Only
+reached on an \code{e2ee} client, which writes the sync cursor after
+the crypto state rather than inside the sync.}
}
\value{
A \code{chat_client} of class \code{chat_matrix}.
diff --git a/man/chat_message.Rd b/man/chat_message.Rd
index 68fbbe0..c810a47 100644
--- a/man/chat_message.Rd
+++ b/man/chat_message.Rd
@@ -3,8 +3,21 @@
\alias{chat_message}
\title{Construct a normalized chat message}
\usage{
-chat_message(id, channel, sender, body, ts, thread = NULL, markup = "plain",
- kind = "message", self = NULL, mentions = NULL, raw = NULL)
+chat_message(
+ id,
+ channel,
+ sender,
+ body,
+ ts,
+ thread = NULL,
+ markup = "plain",
+ kind = "message",
+ self = NULL,
+ mentions = NULL,
+ raw = NULL,
+ encrypted = FALSE,
+ sender_verified = NULL
+)
}
\arguments{
\item{id}{Message identifier (character).}
@@ -46,6 +59,18 @@ exactly as the transport layer handed it over. Shape is
adapter-specific and may already be normalized by the transport
package (Matrix hands over an extracted record, not the timeline
event), so it is an escape hatch, not a guarantee of completeness.}
+
+\item{encrypted}{Logical: did this message arrive end-to-end
+encrypted? FALSE on transports without E2EE and on cleartext
+messages in rooms that have it.}
+
+\item{sender_verified}{Logical: does the sender identifier bind to a
+device whose keys this client verified? NULL on cleartext messages,
+where the transport asserts the sender and there is nothing to
+verify. On an encrypted message FALSE is a real answer, not a
+missing one: the payload decrypted, but its claimed sender could not
+be tied to a verified device, so the identifier is the homeserver's
+word rather than cryptographic fact.}
}
\value{
A list with class \code{chat_message}.
diff --git a/man/chat_send.Rd b/man/chat_send.Rd
index 0b897e1..99c5a8e 100644
--- a/man/chat_send.Rd
+++ b/man/chat_send.Rd
@@ -3,9 +3,19 @@
\alias{chat_send}
\title{Send a message through a chat client}
\usage{
-chat_send(client, channel, text, markup = c("plain", "markdown"),
- thread = NULL, reply_to = NULL, identity = NULL, files = NULL,
- kind = "message", notify = TRUE, ...)
+chat_send(
+ client,
+ channel,
+ text,
+ markup = c("plain", "markdown"),
+ thread = NULL,
+ reply_to = NULL,
+ identity = NULL,
+ files = NULL,
+ kind = "message",
+ notify = TRUE,
+ ...
+)
}
\arguments{
\item{client}{A \code{chat_client}.}
@@ -37,7 +47,13 @@ supported.}
\item{...}{Adapter-specific options.}
}
\value{
-The sent message id (character), invisibly.
+Character vector of the message ids this call created, in the
+ order they were sent, invisibly. Usually length one. A platform that
+ splits a send into several events returns one id per event: the
+ Matrix adapter sends each attachment as its own event, so a send
+ with files returns the attachment ids followed by the text id.
+ Callers that track their own traffic by id must handle every
+ element, or an unclaimed event reads as somebody else's message.
}
\description{
Send a message through a chat client
diff --git a/man/chat_slack.Rd b/man/chat_slack.Rd
index 57b9641..ecbf5fd 100644
--- a/man/chat_slack.Rd
+++ b/man/chat_slack.Rd
@@ -3,8 +3,13 @@
\alias{chat_slack}
\title{Create a Slack chat client}
\usage{
-chat_slack(channels = character(), token = Sys.getenv("SLACK_TOKEN"),
- username = NULL, .history = NULL, .post = NULL)
+chat_slack(
+ channels = character(),
+ token = Sys.getenv("SLACK_TOKEN"),
+ username = NULL,
+ .history = NULL,
+ .post = NULL
+)
}
\arguments{
\item{channels}{Character vector of channels to poll.}