Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/check-results.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
pkg <- "mx.client"
check_dir <- normalizePath(paste0(pkg, ".Rcheck"), mustWork = TRUE)
lines <- readLines(file.path(check_dir, "00check.log"), warn = FALSE)
status <- grep("^Status:", lines, value = TRUE)
if (length(status) != 1L || any(grepl("ERROR|WARNING", status))) {
stop("Missing or failed R CMD check result: ", paste(status, collapse = "; "))
}
cat(status, "\n")
.libPaths(c(check_dir, .libPaths()))
library(pkg, character.only = TRUE, lib.loc = check_dir)
expected <- unname(read.dcf("DESCRIPTION")[1, "Version"])
stopifnot(identical(as.character(utils::packageVersion(pkg)), expected),
utils::packageVersion("mx.crypto") >= "0.2.1.2",
"mxc_sas_commitment" %in% getNamespaceExports("mx.crypto"))
cat("Testing checked build:", find.package(pkg), expected, "\n")
cat("Crypto build:", find.package("mx.crypto"),
as.character(utils::packageVersion("mx.crypto")), "\n")
for (file in c("test_sas.R", "test_sas_identity.R", "test_sas_own_device.R",
"test_sas_transport.R", "test_user_verification.R")) {
result <- tinytest::run_test_file(file.path("inst", "tinytest", file),
at_home = FALSE, verbose = 0, color = FALSE)
print(result)
if (!length(result) || !tinytest::all_pass(result)) {
stop("SAS/identity coverage failed or was skipped: ", file)
}
}
29 changes: 29 additions & 0 deletions .github/install-matrix-deps.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
if (isTRUE(getOption("rapt.enabled"))) rapt::disable()
description <- read.dcf("DESCRIPTION")
requirements <- trimws(strsplit(paste(description[1, c("Imports", "Suggests")],
collapse = ","), ",", fixed = TRUE)[[1]])
floor_for <- function(pkg) {
prefix <- paste0(pkg, " (>= ")
entry <- requirements[startsWith(requirements, prefix)]
if (length(entry) != 1L || !endsWith(entry, ")")) {
stop("Cannot read Matrix dependency floor from DESCRIPTION: ", pkg)
}
substr(entry, nchar(prefix) + 1L, nchar(entry) - 1L)
}
floors <- setNames(vapply(c("mx.api", "mx.crypto"), floor_for, character(1)),
c("mx.api", "mx.crypto"))
ref <- Sys.getenv("MX_CRYPTO_REF")
if (!grepl("^[0-9a-f]{40}$", ref)) stop("CI requires an immutable mx.crypto commit")
utils::install.packages("mx.api", repos = "https://cornball-ai.github.io/drat",
type = "source", dependencies = FALSE)
utils::install.packages(paste0("https://github.com/cornball-ai/mx.crypto/archive/",
ref, ".tar.gz"), repos = NULL, type = "source", dependencies = FALSE)
for (pkg in names(floors)) {
if (!requireNamespace(pkg, quietly = TRUE) ||
utils::packageVersion(pkg) < floors[[pkg]]) {
stop("CI needs ", pkg, " >= ", floors[[pkg]])
}
message(pkg, " ", utils::packageVersion(pkg), " at ", find.package(pkg))
}
stopifnot(utils::packageVersion("mx.crypto") >= "0.2.1.2",
"mxc_sas_commitment" %in% getNamespaceExports("mx.crypto"))
30 changes: 14 additions & 16 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,25 +24,17 @@ jobs:
with:
backend: RAPT

# The cross-signing APIs are development releases published to drat.
# Install them before install_deps checks the hard mx.api floor. rapt
# otherwise selects an older r2u binary by name, ignoring that floor.
# Keep older E2EE dependency floors in DESCRIPTION. SAS needs the
# reviewed crypto build below until it is available on CRAN/drat.
# Disable rapt for these source installs so it cannot substitute an
# older binary. The helper reads the base floors from DESCRIPTION.
- name: Install Matrix development dependencies
env:
# mx.crypto PR #8, merged to main at version 0.2.1.2.
MX_CRYPTO_REF: "7feb052ab40e7ac5620b4d405635a2bc4905984c"
run: |
Rscript -e 'install.packages(c("curl", "jsonlite"))'
Rscript -e '
if (isTRUE(getOption("rapt.enabled"))) rapt::disable()
floors <- c("mx.api" = "0.3.0.2", "mx.crypto" = "0.2.1.1")
utils::install.packages(names(floors),
repos = "https://cornball-ai.github.io/drat",
type = "source", dependencies = FALSE)
for (p in names(floors)) {
if (!requireNamespace(p, quietly = TRUE) ||
utils::packageVersion(p) < floors[[p]])
stop("CI needs ", p, " >= ", floors[[p]],
"; publish the upstream development release to drat first")
message(p, " ", utils::packageVersion(p))
}'
Rscript .github/install-matrix-deps.R

- name: Dependencies
run: ./run.sh install_deps
Expand All @@ -55,3 +47,9 @@ jobs:

- name: Test
run: ./run.sh run_tests
env:
R_BUILD_ARGS: "--no-manual"
R_CHECK_ARGS: "--no-manual --as-cran"

- name: Check warnings and SAS coverage
run: Rscript .github/check-results.R
4 changes: 2 additions & 2 deletions DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
Package: mx.client
Type: Package
Title: Stateful Matrix Client Helpers
Version: 0.2.0.9
Date: 2026-09-09
Version: 0.2.0.10
Date: 2026-09-10
Authors@R: c(
person("Troy", "Hernandez", role = c("aut", "cre"),
email = "troy@cornball.ai",
Expand Down
14 changes: 14 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export(mx_crypto_sessions_load)
export(mx_crypto_sessions_new)
export(mx_crypto_sessions_save)
export(mx_crypto_store_dir)
export(mx_crypto_user_trust)
export(mx_crypto_verify_user)
export(mx_extract_invite_records)
export(mx_extract_invites)
export(mx_extract_media_events)
Expand All @@ -41,13 +43,25 @@ export(mx_pill_mentions)
export(mx_resolve_room)
export(mx_room_encrypted)
export(mx_room_lookup_by_name)
export(mx_sas_accept)
export(mx_sas_cancel)
export(mx_sas_confirm)
export(mx_sas_console)
export(mx_sas_from_request)
export(mx_sas_outgoing)
export(mx_sas_receive)
export(mx_sas_record_trust)
export(mx_sas_session)
export(mx_sas_start)
export(mx_sas_status)
export(mx_send_encrypted)
export(mx_send_media)
export(mx_send_table)
export(mx_send_text)
export(mx_set_displayname)
export(mx_sync_update)
export(mx_table_html)
export(mx_verify_console)
export(mx_with_relogin)

S3method(print,mx_client_config)
26 changes: 26 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,29 @@
# mx.client 0.2.0.10

* Add standard interactive Matrix SAS verification, including emoji/decimal
comparison, modern key agreement and MACs, cancellation, timeouts, stable
outboxes, pinned key snapshots, and read-back-confirmed trust uploads.
SAS requires the optional mx.crypto >= 0.2.1.2; older E2EE paths retain
their existing dependency requirements.
* Add `mx_verify_console()` for explicit, exclusive console ownership of an
existing device and store, plus event-loop hooks without a second sync reader.
Retries reload the saved cursor and credentials and reject identity changes.
Explain valid device-only proofs that omit the peer master, while retaining
the master-key authentication requirement for cross-user identity trust.
* Preserve verification event types and relations through encryption and expose
original `verification_events` separately from normalized chat messages.
Restore outer-only relations used by other clients and reject conflicting
relations or encrypted payloads naming a different room.
* Add pinned, directional user verification from R with existing cross-signing
keys. Signature uploads are idempotent and confirmed by read-back; checking
trust never initializes a store or mutates encryption sessions.
* Expose `identity_verified` on device queries separately from device signature
validity. User-to-user trust requires a signature rooted in the caller's
pinned master. Recipient and forwarded-key admission policies are unchanged.
* Document interactive verification, peer identity recovery in a multi-account
client, the procedural two-account alternative, and missed-room-key recovery.
Distinguish identity trust, device signatures, and live message delivery.

# mx.client 0.2.0.9

## Fixes
Expand Down
15 changes: 12 additions & 3 deletions R/crypto.R
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,8 @@ mx_crypto_inbound_session <- function(session_key) {
#' @param room_id Character. Room id.
#' @param sender_curve25519 Character. This device's Curve25519 key.
#' @param device_id Character. This device's id.
#' @param event_type Inner Matrix event type. Defaults to m.room.message;
#' verification replies use their m.key.verification.* event type.
#' @return A named list: \code{m.room.encrypted} content.
#' @examples
#' \dontrun{
Expand All @@ -403,20 +405,27 @@ mx_crypto_inbound_session <- function(session_key) {
#' }
#' @export
mx_crypto_encrypt_event <- function(megolm_out, content, room_id,
sender_curve25519, device_id) {
sender_curve25519, device_id,
event_type = "m.room.message") {
mx_require_crypto()
if (!sas_scalar(event_type) || !is.list(content)) {
stop("mx.client: invalid encrypted event type or content", call. = FALSE)
}
info <- mx.crypto::mxc_megolm_outbound_info(megolm_out)
payload <- list(type = "m.room.message", room_id = room_id,
payload <- list(type = event_type, room_id = room_id,
content = content)
ct <- mx.crypto::mxc_megolm_encrypt(
megolm_out, charToRaw(mx.api::mx_canonical_json(payload)))
list(
encrypted <- list(
algorithm = MX_MEGOLM,
sender_key = sender_curve25519,
device_id = device_id,
session_id = info$session_id,
ciphertext = ct
)
# Matrix relations must also be visible outside encrypted event content.
encrypted$`m.relates_to` <- content$`m.relates_to`
encrypted
}

#' Decrypt an m.room.encrypted event (Megolm)
Expand Down
53 changes: 49 additions & 4 deletions R/e2ee.R
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ mx_crypto_sessions_load <- function(store_dir) {
#' exactly this shape for devices whose keys verified.
#' @param sender_user_id Character. This user's Matrix id. Required to
#' build a spec-conformant Olm payload that the recipient can attribute.
#' @param event_type Inner Matrix event type, defaulting to m.room.message.
#' @return List with \code{to_device} (per-device payloads), \code{event}
#' (the \code{m.room.encrypted} content), and the updated \code{sessions}.
#' @examples
Expand All @@ -180,7 +181,7 @@ mx_crypto_sessions_load <- function(store_dir) {
mx_crypto_encrypt_for_devices <- function(account, sessions, room_id,
content, sender_curve25519,
device_id, recipients = list(),
sender_user_id = NULL) {
sender_user_id = NULL, event_type = "m.room.message") {
mx_require_crypto()
if (length(recipients) && is.null(sender_user_id)) {
stop("sender_user_id is required to share room keys; without it the ",
Expand Down Expand Up @@ -243,7 +244,7 @@ mx_crypto_encrypt_for_devices <- function(account, sessions, room_id,
}

event <- mx_crypto_encrypt_event(mo$session, content, room_id,
sender_curve25519, device_id)
sender_curve25519, device_id, event_type)
sessions$megolm_out[[room_id]] <- mo
list(to_device = to_device, event = event, sessions = sessions)
}
Expand Down Expand Up @@ -276,6 +277,8 @@ mx_crypto_encrypt_for_devices <- function(account, sessions, room_id,
#' @param self_device_id Character or NULL. This device id. Both this and
#' \code{self_id} are required to create room-key requests.
#' @return List with \code{events} (decrypted, normalized), updated
#' \code{verification_events} (original verification envelopes, separated
#' from chat messages; no handshake or network side effect is performed),
#' \code{sessions}, unsent \code{key_requests}, matching
#' \code{key_request_cancellations}, and \code{incoming_key_requests}
#' for a policy-aware sharing layer to inspect.
Expand All @@ -298,9 +301,14 @@ mx_crypto_process_sync <- function(account, sessions, sync_resp,
sessions$key_requests <- sessions$key_requests %||% list()
cancellations <- list()
incoming_requests <- list()
verification_events <- list()

# 1. To-device: recover shared room keys.
for (ev in sync_resp$to_device$events %||% list()) {
if (sas_is_event(ev)) {
verification_events[[length(verification_events) + 1L]] <- ev
next
}
if (isTRUE(ev$type == "m.room_key_request")) {
c <- ev$content
# Wildcard delivery includes this device; do not surface our own
Expand Down Expand Up @@ -338,7 +346,12 @@ mx_crypto_process_sync <- function(account, sessions, sync_resp,
if (!chk$ok) {
next
}
if (identical(decoded$type, "m.room_key")) {
if (sas_is_event(decoded)) {
if (!identical(decoded$sender, ev$sender)) next
verification_events[[length(verification_events) + 1L]] <- list(
type = decoded$type, content = decoded$content,
sender = decoded$sender)
} else if (identical(decoded$type, "m.room_key")) {
c <- decoded$content
if (!identical(c$algorithm, MX_MEGOLM)) {
next
Expand Down Expand Up @@ -414,6 +427,11 @@ mx_crypto_process_sync <- function(account, sessions, sync_resp,
joined <- sync_resp$rooms$join %||% list()
for (rid in names(joined)) {
for (ev in joined[[rid]]$timeline$events %||% list()) {
if (sas_is_event(ev)) {
ev$room_id <- rid
verification_events[[length(verification_events) + 1L]] <- ev
next
}
if (!isTRUE(ev$type == "m.room.encrypted") ||
!isTRUE(ev$content$algorithm == MX_MEGOLM)) {
next
Expand Down Expand Up @@ -444,7 +462,12 @@ mx_crypto_process_sync <- function(account, sessions, sync_resp,
}
dec <- tryCatch(mx_crypto_decrypt_event(entry$session, ev$content),
error = function(e) NULL)
if (is.null(dec)) {
if (!is.list(dec) || !is.list(dec$content)) {
next
}
if (!identical(dec$room_id, rid)) {
warning("mx.client: dropping encrypted event for a different room",
call. = FALSE)
next
}
# The session was handed to us over Olm by whoever claimed the
Expand All @@ -468,6 +491,27 @@ mx_crypto_process_sync <- function(account, sessions, sync_resp,
verified <- isTRUE(entry$sender_bound)
}
ct <- dec$content
if (sas_is_event(dec)) {
# Some clients move the relation entirely outside the ciphertext.
# Restore it before routing and commitment canonicalization.
outer_relation <- ev$content$`m.relates_to`
inner_relation <- ct$`m.relates_to`
if (!is.null(outer_relation) && !is.null(inner_relation) &&
!isTRUE(tryCatch(identical(
mx.api::mx_canonical_json(outer_relation),
mx.api::mx_canonical_json(inner_relation)),
error = function(e) FALSE))) {
warning("mx.client: dropping verification with conflicting relations",
call. = FALSE)
next
}
if (is.null(inner_relation)) ct$`m.relates_to` <- outer_relation
verification_events[[length(verification_events) + 1L]] <- list(
room_id = rid, event_id = ev$event_id, sender = ev$sender,
origin_server_ts = ev$origin_server_ts,
type = dec$type, content = ct, sender_verified = verified)
next
}
events[[length(events) + 1L]] <- list(
room_id = rid,
event_id = ev$event_id,
Expand All @@ -487,6 +531,7 @@ mx_crypto_process_sync <- function(account, sessions, sync_resp,
function(request) !isTRUE(request$sent), sessions$key_requests))

list(events = events, sessions = sessions,
verification_events = verification_events,
key_requests = key_requests,
key_request_cancellations = cancellations,
incoming_key_requests = incoming_requests)
Expand Down
28 changes: 28 additions & 0 deletions R/identity-trust.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Authenticate other users' master keys through our locally pinned master
# and its user-signing key. Malformed or missing links never grant trust.
mx_crypto_trusted_master_keys <- function(master_keys, user_signing_keys,
self_id, self_master_key) {
valid <- tryCatch({
self <- mx_crypto_cross_signing_public(
master_keys[[self_id]], self_id, "master")
user <- user_signing_keys[[self_id]]
signing_key <- mx_crypto_cross_signing_public(
user, self_id, "user_signing")
if (!identical(self, self_master_key) ||
!mx_crypto_signature_valid(user, self_id, self_master_key)) {
return(list())
}
out <- stats::setNames(list(self_master_key), self_id)
for (uid in setdiff(names(master_keys), self_id)) {
peer <- master_keys[[uid]]
public <- tryCatch(mx_crypto_cross_signing_public(
peer, uid, "master"), error = function(e) NULL)
if (!is.null(public) &&
mx_crypto_signature_valid(peer, self_id, signing_key)) {
out[[uid]] <- public
}
}
out
}, error = function(e) list())
valid
}
Loading