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
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.8
Date: 2026-09-04
Version: 0.2.0.9
Date: 2026-09-09
Authors@R: c(
person("Troy", "Hernandez", role = c("aut", "cre"),
email = "troy@cornball.ai",
Expand Down
16 changes: 16 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
# mx.client 0.2.0.9

## Fixes

* Receive Olm messages on locally initiated sessions as well as remotely
initiated sessions. A peer's room-key reply can now install its Megolm
key and decrypt the associated timeline event.
* Try existing sessions before creating an inbound session from a prekey
message. Failed decryption or prekey creation warns and skips that event
rather than aborting the sync batch; malformed JSON plaintext is contained.
* `mx_crypto_handle_to_device()` accepts existing peer session handles via
the optional `olm_sessions` argument. Undecryptable normal messages now
warn and return `NULL` instead of stopping.
* Session storage retains the existing two-map format. A unified per-peer
session history is deferred; older sessions replaced in a map remain lost.

# mx.client 0.2.0.8

## Documentation
Expand Down
24 changes: 13 additions & 11 deletions R/crypto.R
Original file line number Diff line number Diff line change
Expand Up @@ -324,8 +324,15 @@ mx_crypto_sender_bound <- function(decoded, sender_curve25519, devices) {
#' carries \code{sender_bound}, which is FALSE when no list is supplied
#' or nothing matches; an unbound sender identity is a claim, not a
#' fact, because anyone can open an Olm session to this device.
#' @param olm_sessions List of existing Olm session handles for this sender,
#' whether locally or remotely initiated. Both normal and prekey messages
#' try these sessions first; successful decryption advances the handle in
#' place. With no matching session, only a prekey message can open one.
#' Use \code{mx_crypto_process_sync()} to retain newly created sessions
#' and persist the returned state with \code{mx_crypto_sessions_save()}.
#' @return The decrypted event (a parsed list) with a \code{sender_bound}
#' flag, or NULL if it was not for us or failed the recipient checks.
#' flag, or NULL if it was not for us, could not be decrypted, or failed
#' the recipient checks. Undecryptable messages produce a warning.
#' @examples
#' \dontrun{
#' ev <- mx_crypto_handle_to_device(acct, my_curve, td_content,
Expand All @@ -338,7 +345,7 @@ mx_crypto_sender_bound <- function(decoded, sender_curve25519, devices) {
#' @export
mx_crypto_handle_to_device <- function(account, my_curve25519, content,
self_id = NULL, self_ed25519 = NULL,
devices = NULL) {
devices = NULL, olm_sessions = list()) {
mx_require_crypto()
msg <- content$ciphertext[[my_curve25519]]
if (is.null(msg)) {
Expand All @@ -348,15 +355,10 @@ mx_crypto_handle_to_device <- function(account, my_curve25519, content,
self_ed25519 <- mx.crypto::mxc_account_identity_keys(account)$ed25519
}
sender <- content$sender_key
if (identical(as.integer(msg$type), 0L)) {
res <- mx.crypto::mxc_olm_create_inbound(account,
peer_curve25519 = sender, prekey_b64 = msg$body)
plaintext <- rawToChar(res$plaintext)
} else {
stop("no established Olm session for a non-prekey to-device message",
call. = FALSE)
}
decoded <- jsonlite::fromJSON(plaintext, simplifyVector = FALSE)
res <- olm_receive(account, sender, msg, olm_sessions)
if (is.null(res)) return(NULL)
decoded <- olm_decode(res$plaintext)
if (is.null(decoded)) return(NULL)
chk <- mx_crypto_check_olm_payload(decoded, self_id, self_ed25519, sender,
devices)
if (!chk$ok) {
Expand Down
17 changes: 6 additions & 11 deletions R/e2ee.R
Original file line number Diff line number Diff line change
Expand Up @@ -325,19 +325,14 @@ mx_crypto_process_sync <- function(account, sessions, sync_resp,
next
}
sender <- ev$content$sender_key
plaintext <- if (identical(as.integer(msg$type), 0L)) {
res <- mx.crypto::mxc_olm_create_inbound(account,
peer_curve25519 = sender, prekey_b64 = msg$body)
res <- olm_receive(account, sender, msg,
list(sessions$olm_in[[sender]], sessions$olm[[sender]]))
if (is.null(res)) next
if (res$new) {
sessions$olm_in[[sender]] <- res$session
rawToChar(res$plaintext)
} else {
s <- sessions$olm_in[[sender]]
if (is.null(s)) {
next
}
rawToChar(mx.crypto::mxc_olm_decrypt(s, msg$type, msg$body))
}
decoded <- jsonlite::fromJSON(plaintext, simplifyVector = FALSE)
decoded <- olm_decode(res$plaintext)
if (is.null(decoded)) next
chk <- mx_crypto_check_olm_payload(decoded, self_id, self_ed25519,
sender, devices)
if (!chk$ok) {
Expand Down
52 changes: 52 additions & 0 deletions R/olm-receive.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# An Olm session is bidirectional. The two persisted maps describe who
# initiated it, not which direction its messages can travel. Try both maps
# before consuming another one-time key; failed decrypts leave the session
# unchanged in vodozemac.
olm_receive <- function(account, sender, msg, sessions = list()) {
if (!is.list(msg) || !is.numeric(msg$type) || length(msg$type) != 1L ||
!isTRUE(msg$type %in% c(0L, 1L)) ||
!is.character(msg$body) || length(msg$body) != 1L ||
is.na(msg$body) || !is.character(sender) || length(sender) != 1L ||
is.na(sender) || !nzchar(sender)) {
warning("mx.client: dropping malformed Olm to-device message",
call. = FALSE)
return(NULL)
}
for (session in sessions) {
if (is.null(session)) next
plaintext <- tryCatch(
mx.crypto::mxc_olm_decrypt(session, msg$type, msg$body),
error = function(e) NULL)
if (!is.null(plaintext)) {
return(list(session = session, plaintext = plaintext, new = FALSE))
}
}
if (msg$type == 0L) {
# A repeated prekey may no longer have an OTK. Do not let that
# failure abort a sync batch whose earlier messages advanced ratchets.
result <- tryCatch(mx.crypto::mxc_olm_create_inbound(
account, peer_curve25519 = sender, prekey_b64 = msg$body),
error = function(e) NULL)
if (!is.null(result)) {
result$new <- TRUE
return(result)
}
}
warning("mx.client: cannot decrypt Olm to-device message with ",
"available sessions; dropping it", call. = FALSE)
NULL
}

# Parsing follows a successful, state-mutating decrypt. A malformed payload
# must not discard the updated session or prevent processing the next event.
olm_decode <- function(plaintext) {
decoded <- tryCatch(jsonlite::fromJSON(rawToChar(plaintext),
simplifyVector = FALSE),
error = function(e) NULL)
if (!is.list(decoded)) {
warning("mx.client: dropping malformed Olm plaintext",
call. = FALSE)
return(NULL)
}
decoded
}
Loading
Loading