diff --git a/DESCRIPTION b/DESCRIPTION index 2c3ebba..e786177 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,8 +1,8 @@ Package: mx.client Type: Package Title: Stateful Matrix Client Helpers -Version: 0.2.1.1 -Date: 2026-10-02 +Version: 0.2.1.3 +Date: 2026-10-07 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), email = "troy@cornball.ai", diff --git a/NEWS.md b/NEWS.md index 357cb3d..0e4e66a 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,42 @@ +# mx.client 0.2.1.3 + +* Fix: keep every inbound Olm session per peer, not just the latest. A + peer can open several Olm sessions to us over its lifetime and reply on + any of them; the single inbound slot per peer was overwritten by each + new session, so a message sent on a session we had replaced could not be + decrypted and was dropped. Found when a FluffyChat MatrixRTC call key + arrived on a session the bot had since replaced, leaving the caller's + audio undecryptable (`MISSING_KEY`). `olm_in` now holds a list of + sessions per peer Curve25519 key; `mx_crypto_process_sync()` appends new + inbound sessions instead of replacing, and `olm_receive()` tries them + all (plus the outbound session) on receive. Stores written with one + session per peer load unchanged (the bare entry becomes a one-element + list). +* The "cannot decrypt Olm to-device message" warning now reports the + message type and how many sessions were tried, and includes the + `create_inbound` error for a failed prekey, so a dropped message is + diagnosable instead of silent. + +# mx.client 0.2.1.2 + +* Fix: make MatrixRTC call encryption keys actually work, found during + the first live FluffyChat-to-bot call (the bot heard only undecryptable + silence). + - `keys` is an array of `{index, key}`, not a single object; + `mx_call_key_content()` and `mx_call_key_parse()` treated it as one, + so real key events (parsed off the wire with `simplifyVector = + FALSE`) were dropped. The parser now reads the array and returns one + entry per key. + - FluffyChat / matrix-dart-sdk sends the key as an encrypted ROOM + event (top-level `device_id`), not to-device. `mx_crypto_process_sync()` + now keeps each decrypted room event's `type` and `content`, + `mx_call_handle()` reads call keys from `processed$events` as well as + `to_device`, and `mx_call_key_parse()` accepts the room-event shape + (top-level `device_id`, room taken from the event). + - `mx_call_send_key()` also posts the key as an encrypted room event so + FluffyChat can decrypt the bot; `mx_send_encrypted()` gained an + `event_type` argument. + # mx.client 0.2.1.1 ## MatrixRTC calls diff --git a/R/call.R b/R/call.R index 3cec94d..6c24815 100644 --- a/R/call.R +++ b/R/call.R @@ -174,10 +174,18 @@ mx_call_b64_decode <- function(x) { # Content of a key event: the key the receiver should use for this # device's media. FluffyChat casts every field but sent_ts, so all of them # are always present. +# A superset that both consumers accept: FluffyChat / matrix-dart-sdk +# reads top-level call_id + device_id + keys (it sends and expects the key +# as a room event); Element Call reads member + keys (to-device). Sending +# this one object by either transport reaches both. mx_call_key_content <- function(key, index, user_id, device_id, room_id, now = Sys.time()) { list( - keys = list(index = as.integer(index), key = mx_call_b64(key)), + call_id = "", + device_id = device_id, + # keys is an array of {index, key}; the wrapping list makes JSON + # serialize it as an array even with a single entry. + keys = list(list(index = as.integer(index), key = mx_call_b64(key))), member = list(id = mx_call_identity(user_id, device_id), claimed_device_id = device_id), room_id = room_id, @@ -186,40 +194,81 @@ mx_call_key_content <- function(key, index, user_id, device_id, room_id, ) } +# Normalize the content's `keys` field to a list of {index, key}. The +# wire sends an array; depending on how the Olm plaintext was parsed it +# arrives as an unnamed list of objects (simplifyVector = FALSE), a data +# frame (simplifyVector = TRUE), and we also tolerate a single {index, +# key} object from a non-conformant sender. +mx_call_key_entries <- function(keys) { + if (is.null(keys)) { + return(list()) + } + if (is.data.frame(keys)) { + return(lapply(seq_len(nrow(keys)), function(i) { + list(index = keys$index[[i]], key = keys$key[[i]]) + })) + } + if (!is.null(names(keys)) && !is.null(keys$index)) { + return(list(list(index = keys$index, key = keys$key))) + } + if (is.list(keys)) { + return(Filter(function(e) is.list(e) && !is.null(e$index), keys)) + } + list() +} + #' Read a call key event #' #' Parses a decrypted \code{io.element.call.encryption_keys} to-device #' event (from the \code{to_device} list of -#' \code{\link{mx_crypto_process_sync}}) into the LiveKit identity it -#' belongs to and the key to set for it. +#' \code{\link{mx_crypto_process_sync}}) into the LiveKit identities and +#' keys to set. The event's \code{keys} field is an array, so one event +#' can carry several keys at different indices; each becomes one entry. #' #' @param event List with \code{type}, \code{content} and \code{sender}. #' @param room_id The call's room; keys for other rooms are ignored. -#' @return \code{list(identity, key, index)}, with \code{key} a raw -#' vector, or NULL when the event is not a usable key for this room. +#' @return A list of \code{list(identity, key, index)}, with \code{key} a +#' raw vector, one per usable key in the event; empty when the event is +#' not a usable key for this room. #' @examples #' ev <- list(type = "io.element.call.encryption_keys", #' sender = "@alice:example.org", -#' content = list(keys = list(index = 3, key = "AAECAwQFBgcICQoLDA0ODw=="), +#' content = list( +#' keys = list(list(index = 3, key = "AAECAwQFBgcICQoLDA0ODw==")), #' member = list(claimed_device_id = "PHONE"), room_id = "!r:example.org")) #' mx_call_key_parse(ev, "!r:example.org") #' @export mx_call_key_parse <- function(event, room_id) { - if (!identical(event$type, MX_CALL_KEYS)) return(NULL) + if (!identical(event$type, MX_CALL_KEYS)) return(list()) c <- event$content - if (!identical(c$room_id, room_id)) return(NULL) - device <- c$member$claimed_device_id - index <- c$keys$index - key <- c$keys$key + # A to-device event names its room in the content; a room event does + # not (its room is the event's own, matched by the caller). Enforce + # content$room_id only when it is present. + if (!is.null(c$room_id) && !identical(c$room_id, room_id)) return(list()) + # Element Call uses member$claimed_device_id; FluffyChat uses a + # top-level device_id. + device <- c$member$claimed_device_id %||% c$device_id if (!is.character(event$sender) || !is.character(device) || - !nzchar(device) || !is.numeric(index) || length(index) != 1L || - index < 0 || index != floor(index) || !is.character(key)) { - return(NULL) + !nzchar(device)) { + return(list()) + } + identity <- mx_call_identity(event$sender, device) + out <- list() + for (k in mx_call_key_entries(c$keys)) { + index <- k$index + key <- k$key + if (!is.numeric(index) || length(index) != 1L || index < 0 || + index != floor(index) || !is.character(key) || length(key) != 1L) { + next + } + bytes <- tryCatch(mx_call_b64_decode(key), error = function(e) NULL) + if (is.null(bytes) || !length(bytes)) { + next + } + out[[length(out) + 1L]] <- list(identity = identity, key = bytes, + index = as.integer(index)) } - bytes <- tryCatch(mx_call_b64_decode(key), error = function(e) NULL) - if (is.null(bytes) || !length(bytes)) return(NULL) - list(identity = mx_call_identity(event$sender, device), key = bytes, - index = as.integer(index)) + out } # The key state of one call: our key and index, when the key was made, @@ -334,6 +383,19 @@ mx_call_send_key <- function(call, targets) { }, "") call$keys$shared_with <- union(call$keys$shared_with, intersect(targets, reached)) + # FluffyChat / matrix-dart-sdk reads the key from an encrypted + # ROOM event, not to-device, so also post it there. Guarded: a + # failure here must not break the to-device path or the call. + tryCatch({ + user_ids <- unique(sub(":[^:]+$", "", targets)) + room_res <- mx_send_encrypted(call$client, call$account, + call$sessions, call$room_id, content, call$store_dir, + member_ids = user_ids, event_type = MX_CALL_KEYS) + call$sessions <- room_res$sessions + }, error = function(e) { + warning("mx.client: could not post the call key as a room event: ", + conditionMessage(e), call. = FALSE) + }) } if (!is.null(call$session)) { livekitr::lk_set_e2ee_key(call$session, call$keys$key, @@ -520,9 +582,20 @@ mx_call_handle <- function(call, sync, processed = NULL) { call$sessions <- processed$sessions } received <- character() - for (ev in processed$to_device %||% list()) { - parsed <- mx_call_key_parse(ev, call$room_id) - if (!is.null(parsed)) { + # Call keys arrive two ways: Element Call sends them to-device, while + # FluffyChat / matrix-dart-sdk sends them as an encrypted ROOM event + # (surfaced in processed$events with its type and content). Read both. + key_events <- c(processed$to_device %||% list(), + Filter(function(ev) identical(ev$room_id, call$room_id), + processed$events %||% list())) + for (ev in key_events) { + for (parsed in mx_call_key_parse(ev, call$room_id)) { + # Our own key, posted as a room event, echoes back through our + # sync; it is not a peer key and must not be applied to our own + # participant slot. + if (identical(parsed$identity, call$identity)) { + next + } mx_call_apply_peer_key(call, parsed) received <- c(received, parsed$identity) } diff --git a/R/e2ee.R b/R/e2ee.R index f1dca78..46ae8ab 100644 --- a/R/e2ee.R +++ b/R/e2ee.R @@ -7,7 +7,12 @@ # # A session set is a list with five named maps: # olm peer Curve25519 -> outbound Olm session (we encrypt to them) -# olm_in peer Curve25519 -> inbound Olm session (they encrypt to us) +# olm_in peer Curve25519 -> list of inbound Olm sessions (they encrypt +# to us). A peer may open several sessions to +# us over its lifetime and reply on any of +# them, so we keep every one and try them all +# on receive; a single slot would drop a +# message sent on a session we had replaced. # megolm_out room id -> list(session, shared = peer curves) # megolm_in "room|session_id" -> list(session, sender, sender_ed25519, # sender_bound) @@ -60,8 +65,8 @@ mx_crypto_sessions_save <- function(sessions, store_dir) { olm = lapply(sessions$olm, function(s) { mx.crypto::mxc_olm_session_pickle(s, key) }), - olm_in = lapply(sessions$olm_in, function(s) { - mx.crypto::mxc_olm_session_pickle(s, key) + olm_in = lapply(sessions$olm_in, function(slist) { + lapply(slist, function(s) mx.crypto::mxc_olm_session_pickle(s, key)) }), megolm_out = lapply(sessions$megolm_out, function(m) { list(session = mx.crypto::mxc_megolm_outbound_pickle(m$session, key), @@ -111,8 +116,13 @@ mx_crypto_sessions_load <- function(store_dir) { out$olm[[nm]] <- mx.crypto::mxc_olm_session_unpickle(blob$olm[[nm]], key) } for (nm in names(blob$olm_in %||% list())) { - out$olm_in[[nm]] <- mx.crypto::mxc_olm_session_unpickle( - blob$olm_in[[nm]], key) + # A store written before olm_in became a list holds a bare pickle + # string per peer; wrap it so the in-memory shape is always a list. + v <- blob$olm_in[[nm]] + pickles <- if (is.list(v)) v else list(v) + out$olm_in[[nm]] <- lapply(pickles, function(p) { + mx.crypto::mxc_olm_session_unpickle(p, key) + }) } for (nm in names(blob$megolm_out %||% list())) { m <- blob$megolm_out[[nm]] @@ -282,7 +292,9 @@ mx_crypto_encrypt_for_devices <- function(account, sessions, room_id, #' \code{is_self} tagging and as the recipient of key requests. #' @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 +#' @return List with \code{events} (decrypted, normalized; each also +#' carries its inner \code{type} and \code{content}, so non-message +#' room events such as call encryption keys are usable), 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 @@ -343,11 +355,17 @@ mx_crypto_process_sync <- function(account, sessions, sync_resp, next } sender <- ev$content$sender_key + # Try every inbound session we hold for this peer, then the outbound + # one (Olm sessions are bidirectional). Keeping all of them is what + # lets a reply decrypt when the peer picked a session we would + # otherwise have overwritten. res <- olm_receive(account, sender, msg, - list(sessions$olm_in[[sender]], sessions$olm[[sender]])) + c(sessions$olm_in[[sender]] %||% list(), + list(sessions$olm[[sender]]))) if (is.null(res)) next if (res$new) { - sessions$olm_in[[sender]] <- res$session + sessions$olm_in[[sender]] <- c(sessions$olm_in[[sender]] %||% list(), + list(res$session)) } decoded <- olm_decode(res$plaintext) if (is.null(decoded)) next @@ -536,6 +554,12 @@ mx_crypto_process_sync <- function(account, sessions, sync_resp, sender = ev$sender, sender_verified = verified, is_self = isTRUE(ev$sender == self_id), + # The inner type and content, kept so non-message room + # events (e.g. io.element.call.encryption_keys, which + # FluffyChat sends as a room event) are usable by callers + # that look past text. Additive; text consumers read body. + type = dec$type, + content = ct, body = ct$body, msgtype = ct$msgtype, mentions = ct$`m.mentions`$user_ids diff --git a/R/olm-receive.R b/R/olm-receive.R index cd2bd25..f9f1cb0 100644 --- a/R/olm-receive.R +++ b/R/olm-receive.R @@ -12,8 +12,10 @@ olm_receive <- function(account, sender, msg, sessions = list()) { call. = FALSE) return(NULL) } + n_tried <- 0L for (session in sessions) { if (is.null(session)) next + n_tried <- n_tried + 1L plaintext <- tryCatch( mx.crypto::mxc_olm_decrypt(session, msg$type, msg$body), error = function(e) NULL) @@ -21,19 +23,30 @@ olm_receive <- function(account, sender, msg, sessions = list()) { return(list(session = session, plaintext = plaintext, new = FALSE)) } } + create_err <- NULL 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) + error = function(e) { + create_err <<- conditionMessage(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) + # The type and the count of sessions actually tried turn a silent "it + # just failed" into something diagnosable: a type-1 reply that no stored + # session decrypts means we are missing the session the sender chose + # (keep every session per peer and try them all); a type-0 failure + # carries the create_inbound error (a spent or malformed one-time key). + warning("mx.client: cannot decrypt Olm to-device message (type ", + msg$type, ", ", n_tried, " session(s) tried", + if (!is.null(create_err)) paste0(", create_inbound: ", create_err) + else "", "); dropping it", call. = FALSE) NULL } diff --git a/R/transport.R b/R/transport.R index f29e3e2..f9cc35a 100644 --- a/R/transport.R +++ b/R/transport.R @@ -293,6 +293,8 @@ mx_crypto_verify_claimed_otks <- function(devices, claimed) { #' a room whose other members have no devices is a real room. #' @param member_ids Character vector of room member user ids (used when #' \code{recipients} is NULL). +#' @param event_type Inner Matrix event type, defaulting to +#' \code{m.room.message}. #' @return List with \code{event_id} and the updated \code{sessions}. #' @examples #' \dontrun{ @@ -302,7 +304,8 @@ mx_crypto_verify_claimed_otks <- function(devices, claimed) { #' } #' @export mx_send_encrypted <- function(client, account, sessions, room_id, content, - store_dir, recipients = NULL, member_ids = NULL) { + store_dir, recipients = NULL, member_ids = NULL, + event_type = "m.room.message") { mx_require_crypto() s <- mx_client_session(client) sender_curve <- mx.crypto::mxc_account_identity_keys(account)$curve25519 @@ -376,7 +379,7 @@ mx_send_encrypted <- function(client, account, sessions, room_id, content, out <- mx_crypto_encrypt_for_devices(account, sessions, room_id, content, sender_curve, client$device_id, recipients = recipients, - sender_user_id = client$user_id) + sender_user_id = client$user_id, event_type = event_type) for (p in out$to_device) { messages <- stats::setNames( list(stats::setNames(list(p$content), p$device_id)), p$user_id) diff --git a/inst/tinytest/test_call.R b/inst/tinytest/test_call.R index 9ce4548..1cdca8c 100644 --- a/inst/tinytest/test_call.R +++ b/inst/tinytest/test_call.R @@ -121,43 +121,109 @@ expect_error( key <- as.raw(0:15) kc <- ns$mx_call_key_content(key, 3L, "@bot:example.org", "BOTDEV", ROOM, now = T0) -expect_identical(kc$keys$index, 3L) -expect_identical(kc$keys$key, "AAECAwQFBgcICQoLDA0ODw==") +# keys is an ARRAY of {index, key} (MSC + Element/FluffyChat wire shape). +expect_true(is.null(names(kc$keys))) +expect_identical(length(kc$keys), 1L) +expect_identical(kc$keys[[1]]$index, 3L) +expect_identical(kc$keys[[1]]$key, "AAECAwQFBgcICQoLDA0ODw==") +# Superset content: FluffyChat's top-level device_id/call_id AND Element's +# member block, so one object serves both transports. +expect_identical(kc$device_id, "BOTDEV") +expect_identical(kc$call_id, "") expect_identical(kc$member$id, "@bot:example.org:BOTDEV") expect_identical(kc$member$claimed_device_id, "BOTDEV") expect_identical(kc$room_id, ROOM) expect_identical(kc$session$application, "m.call") expect_equal(kc$sent_ts, 1.7e12) +# It must serialize with keys as a JSON array of objects. +kc_json <- jsonlite::toJSON(kc, auto_unbox = TRUE) +expect_true(grepl('"keys":\\[\\{"index":3,"key":', kc_json)) -parsed <- mx_call_key_parse(list(type = "io.element.call.encryption_keys", - sender = "@bot:example.org", content = kc), - ROOM) -expect_identical(parsed$identity, "@bot:example.org:BOTDEV") -expect_identical(parsed$key, key) -expect_identical(parsed$index, 3L) -# Unpadded and URL-safe base64 decode too -kc2 <- kc -kc2$keys$key <- "AAECAwQFBgcICQoLDA0ODw" -expect_identical(mx_call_key_parse(list(type = "io.element.call.encryption_keys", - sender = "@bot:example.org", content = kc2), ROOM)$key, key) +# A FluffyChat room-event key: top-level device_id, no member, no room_id +# in content (the room is the event's own). Parsed with simplifyVector +# = FALSE as off the wire. +room_ev <- jsonlite::fromJSON(paste0( + '{"type":"io.element.call.encryption_keys","sender":"@al:example.org",', + '"room_id":"', ROOM, '","content":{"call_id":"","device_id":"PHONE",', + '"keys":[{"index":2,"key":"AAECAwQFBgcICQoLDA0ODw"}],"sent_ts":1}}'), + simplifyVector = FALSE) +rp <- mx_call_key_parse(room_ev, ROOM) +expect_identical(length(rp), 1L) +expect_identical(rp[[1]]$identity, "@al:example.org:PHONE") +expect_identical(rp[[1]]$index, 2L) +expect_identical(rp[[1]]$key, key) + +# A spec-shaped event as it arrives off the wire: Olm plaintext is parsed +# with simplifyVector = FALSE, so keys is a list of lists. This is the +# exact shape that regressed in 0.2.1.1. +wire <- jsonlite::fromJSON(paste0( + '{"type":"io.element.call.encryption_keys","sender":"@bot:example.org",', + '"content":{"call_id":"","keys":[{"index":3,', + '"key":"AAECAwQFBgcICQoLDA0ODw"}],', + '"member":{"claimed_device_id":"BOTDEV","id":"@bot:example.org"},', + '"room_id":"', ROOM, '","sent_ts":1}}'), simplifyVector = FALSE) +parsed <- mx_call_key_parse(wire, ROOM) +expect_identical(length(parsed), 1L) +expect_identical(parsed[[1]]$identity, "@bot:example.org:BOTDEV") +expect_identical(parsed[[1]]$key, key) +expect_identical(parsed[[1]]$index, 3L) + +# Our own content round-trips through the parser. +rt <- mx_call_key_parse(list(type = "io.element.call.encryption_keys", + sender = "@bot:example.org", content = kc), ROOM) +expect_identical(length(rt), 1L) +expect_identical(rt[[1]]$key, key) +expect_identical(rt[[1]]$index, 3L) + +# Several keys in one event: each becomes an entry. +multi <- list(type = "io.element.call.encryption_keys", + sender = "@bot:example.org", + content = list(room_id = ROOM, + member = list(claimed_device_id = "BOTDEV"), + keys = list(list(index = 0, key = "AAA="), + list(index = 1, key = "AQE=")))) +pm <- mx_call_key_parse(multi, ROOM) +expect_identical(length(pm), 2L) +expect_identical(vapply(pm, `[[`, 0L, "index"), c(0L, 1L)) + +# Tolerate a non-conformant sender that puts keys as a single object. +obj <- list(type = "io.element.call.encryption_keys", + sender = "@bot:example.org", + content = list(room_id = ROOM, + member = list(claimed_device_id = "BOTDEV"), + keys = list(index = 3, key = "AAECAwQFBgcICQoLDA0ODw"))) +expect_identical(mx_call_key_parse(obj, ROOM)[[1]]$key, key) + +# Unpadded and URL-safe base64 decode too. kc3 <- kc -kc3$keys$key <- jsonlite::base64url_enc(as.raw(c(0xfb, 0xff, 0xfe, 1))) +kc3$keys[[1]]$key <- jsonlite::base64url_enc(as.raw(c(0xfb, 0xff, 0xfe, 1))) expect_identical(mx_call_key_parse(list(type = "io.element.call.encryption_keys", - sender = "@bot:example.org", content = kc3), ROOM)$key, + sender = "@bot:example.org", content = kc3), ROOM)[[1]]$key, as.raw(c(0xfb, 0xff, 0xfe, 1))) -# Rejections: other room, other type, bad index, missing device -expect_null(mx_call_key_parse(list(type = "io.element.call.encryption_keys", - sender = "@bot:example.org", content = kc), "!other:example.org")) -expect_null(mx_call_key_parse(list(type = "m.room_key", sender = "@b:ex", - content = kc), ROOM)) + +# Rejections return an empty list: other room, other type, bad index, +# missing device. +expect_identical(mx_call_key_parse(list(type = "io.element.call.encryption_keys", + sender = "@bot:example.org", content = kc), "!other:example.org"), list()) +expect_identical(mx_call_key_parse(list(type = "m.room_key", sender = "@b:ex", + content = kc), ROOM), list()) kc4 <- kc -kc4$keys$index <- -1 -expect_null(mx_call_key_parse(list(type = "io.element.call.encryption_keys", - sender = "@bot:example.org", content = kc4), ROOM)) +kc4$keys[[1]]$index <- -1 +expect_identical(mx_call_key_parse(list(type = "io.element.call.encryption_keys", + sender = "@bot:example.org", content = kc4), ROOM), list()) +# No device at all (neither member$claimed_device_id nor top-level +# device_id): unparseable. kc5 <- kc kc5$member$claimed_device_id <- NULL -expect_null(mx_call_key_parse(list(type = "io.element.call.encryption_keys", - sender = "@bot:example.org", content = kc5), ROOM)) +kc5$device_id <- NULL +expect_identical(mx_call_key_parse(list(type = "io.element.call.encryption_keys", + sender = "@bot:example.org", content = kc5), ROOM), list()) +# member alone still works when device_id is absent (Element Call shape). +kc6 <- kc +kc6$device_id <- NULL +expect_identical(mx_call_key_parse(list(type = "io.element.call.encryption_keys", + sender = "@bot:example.org", content = kc6), ROOM)[[1]]$identity, + "@bot:example.org:BOTDEV") # ---- rotation policy -------------------------------------------------- @@ -257,18 +323,45 @@ local({ "PHONE", ROOM) other_room <- ns$mx_call_key_content(as.raw(1:16), 2L, "@alice:example.org", "PHONE", "!other:example.org") - processed <- list(to_device = list( - list(type = "io.element.call.encryption_keys", - sender = "@alice:example.org", content = peer_key), - list(type = "io.element.call.encryption_keys", - sender = "@alice:example.org", content = other_room), - list(type = "org.example.other", sender = "@x:ex", content = list()))) + # A room-event key (FluffyChat): no content room_id, room is the + # record's room_id; a non-call room event and a wrong-room key event + # must be ignored. + room_key <- list(type = "io.element.call.encryption_keys", + sender = "@bob:example.org", room_id = ROOM, + content = list(call_id = "", device_id = "LAPTOP", + keys = list(list(index = 4, key = "AAECAwQFBgcICQoLDA0ODw")))) + processed <- list( + to_device = list( + list(type = "io.element.call.encryption_keys", + sender = "@alice:example.org", content = peer_key), + list(type = "io.element.call.encryption_keys", + sender = "@alice:example.org", content = other_room), + list(type = "org.example.other", sender = "@x:ex", content = list())), + events = list( + room_key, + list(type = "m.room.message", sender = "@c:ex", room_id = ROOM, + content = list(body = "hi")), + list(type = "io.element.call.encryption_keys", sender = "@d:ex", + room_id = "!elsewhere:example.org", + content = list(device_id = "X", + keys = list(list(index = 9, key = "AAA=")))), + # Our own key, echoed back through our sync: must be skipped. + list(type = "io.element.call.encryption_keys", + sender = "@bot:example.org", room_id = ROOM, + content = list(device_id = "BOTDEV", + keys = list(list(index = 7, key = "AAECAwQFBgcICQoLDA0ODw")))))) quiet <- list(rooms = list(join = list())) res <- mx_call_handle(call, quiet, processed) - expect_identical(res$keys, "@alice:example.org:PHONE") + expect_identical(sort(res$keys), + c("@alice:example.org:PHONE", "@bob:example.org:LAPTOP")) expect_null(res$members) - expect_identical(applied, list("@alice:example.org:PHONE")) + expect_identical(sort(unlist(applied)), + c("@alice:example.org:PHONE", "@bob:example.org:LAPTOP")) expect_identical(call$keys$peers[["@alice:example.org:PHONE"]]$index, 2L) + expect_identical(call$keys$peers[["@bob:example.org:LAPTOP"]]$index, 4L) + # Our own echoed key was not applied as a peer. + expect_false("@bot:example.org:BOTDEV" %in% res$keys) + expect_null(call$keys$peers[["@bot:example.org:BOTDEV"]]) # A membership event in the room's timeline triggers a state refresh with_state <- list(rooms = list(join = stats::setNames(list(list( diff --git a/inst/tinytest/test_olm_receive.R b/inst/tinytest/test_olm_receive.R index 3d01821..19d2ecf 100644 --- a/inst/tinytest/test_olm_receive.R +++ b/inst/tinytest/test_olm_receive.R @@ -74,9 +74,9 @@ local({ f$ak$curve25519, mx.crypto::mxc_account_one_time_keys(f$alice)[[1L]]) pre <- mx.crypto::mxc_olm_encrypt(other, charToRaw("other session")) - sessions$olm_in[[f$bk$curve25519]] <- + sessions$olm_in[[f$bk$curve25519]] <- list( mx.crypto::mxc_olm_create_inbound(f$alice, - f$bk$curve25519, pre$body)$session + f$bk$curve25519, pre$body)$session) } msg <- share(f$inc, f$bk, f$ak, bob_id, alice_id) expect_identical(msg$content$ciphertext[[f$ak$curve25519]]$type, 1L) @@ -120,7 +120,7 @@ local({ # session remain prekey messages. Bob's OTK has already been consumed. f <- pair() sessions <- mx_crypto_sessions_new() - sessions$olm_in[[f$ak$curve25519]] <- f$inc + sessions$olm_in[[f$ak$curve25519]] <- list(f$inc) msg <- share(f$out, f$ak, f$bk, alice_id, bob_id, "second prekey") expect_identical(msg$content$ciphertext[[f$bk$curve25519]]$type, 0L) expect_equal(length(mx.crypto::mxc_account_one_time_keys(f$bob)), 0L) @@ -129,7 +129,8 @@ local({ if (!inherits(res, "error")) { expect_equal(length(res$events), 1L) expect_true(msg$key %in% names(res$sessions$megolm_in)) - expect_identical(res$sessions$olm_in[[f$ak$curve25519]], f$inc) + expect_equal(length(res$sessions$olm_in[[f$ak$curve25519]]), 1L) + expect_identical(res$sessions$olm_in[[f$ak$curve25519]][[1L]], f$inc) # Exact replay cannot decrypt twice, but it must not abort the batch # or prevent a later valid prekey message from installing its key. @@ -189,25 +190,28 @@ local({ msg <- share(f$out, f$ak, f$bk, alice_id, bob_id) expect_identical(msg$content$ciphertext[[f$bk$curve25519]]$type, 1L) sessions <- mx_crypto_sessions_new() - sessions$olm_in[[f$ak$curve25519]] <- f$inc + sessions$olm_in[[f$ak$curve25519]] <- list(f$inc) res <- process(f, sessions, list(msg), alice = FALSE) expect_false(inherits(res, "error")) if (!inherits(res, "error")) expect_equal(length(res$events), 1L) # A genuinely new prekey session still opens after all existing - # sessions fail, replacing only the inbound entry for that peer. + # sessions fail, and is kept alongside the peer's existing inbound + # sessions rather than replacing them, so a later message on either + # session still decrypts. f <- pair() mx.crypto::mxc_account_generate_one_time_keys(f$bob, 1L) fresh <- mx.crypto::mxc_olm_create_outbound(f$alice, f$bk$curve25519, mx.crypto::mxc_account_one_time_keys(f$bob)[[1L]]) msg <- share(fresh, f$ak, f$bk, alice_id, bob_id) sessions <- mx_crypto_sessions_new() - sessions$olm_in[[f$ak$curve25519]] <- f$inc + sessions$olm_in[[f$ak$curve25519]] <- list(f$inc) res <- process(f, sessions, list(msg), alice = FALSE) expect_false(inherits(res, "error")) if (!inherits(res, "error")) { expect_equal(length(res$events), 1L) - expect_false(identical(res$sessions$olm_in[[f$ak$curve25519]], f$inc)) + expect_equal(length(res$sessions$olm_in[[f$ak$curve25519]]), 2L) + expect_identical(res$sessions$olm_in[[f$ak$curve25519]][[1L]], f$inc) expect_equal(length(mx.crypto::mxc_account_one_time_keys(f$bob)), 0L) } @@ -269,4 +273,56 @@ local({ olm_sessions = list(f$inc)), "malformed Olm to-device") expect_null(result) } + + # Regression (the MatrixRTC call-key failure): a peer opens a second + # session to us, and later replies on the FIRST. The first inbound + # session must be kept, not overwritten by the second, or the reply is + # dropped. Its one-time key is long spent, so a single-slot store that + # had replaced it could not reopen it either. + f <- pair() # Alice O1 -> Bob I1 (f$inc) + sessions <- mx_crypto_sessions_new() + sessions$olm_in[[f$ak$curve25519]] <- list(f$inc) + mx.crypto::mxc_account_generate_one_time_keys(f$bob, 1L) + o2 <- mx.crypto::mxc_olm_create_outbound(f$alice, f$bk$curve25519, + mx.crypto::mxc_account_one_time_keys(f$bob)[[1L]]) + m2 <- share(o2, f$ak, f$bk, alice_id, bob_id, "on second session") + expect_identical(m2$content$ciphertext[[f$bk$curve25519]]$type, 0L) + res <- process(f, sessions, list(m2), alice = FALSE) + expect_false(inherits(res, "error")) + if (!inherits(res, "error")) { + expect_equal(length(res$sessions$olm_in[[f$ak$curve25519]]), 2L) + m1 <- share(f$out, f$ak, f$bk, alice_id, bob_id, "back on first") + res1 <- process(f, res$sessions, list(m1), alice = FALSE) + expect_false(inherits(res1, "error")) + if (!inherits(res1, "error")) { + expect_equal(length(res1$events), 1L) + if (length(res1$events)) { + expect_identical(res1$events[[1L]]$body, "back on first") + } + } + } + + # Backward compatibility: a store written before olm_in was a list holds + # one bare pickle string per peer. It loads into a one-element list and + # the session still decrypts. + f <- pair() + sessions <- mx_crypto_sessions_new() + sessions$olm_in[[f$ak$curve25519]] <- list(f$inc) + store2 <- tempfile("olm-legacy-") + on.exit(unlink(store2, recursive = TRUE), add = TRUE) + mx_crypto_sessions_save(sessions, store2) + path <- file.path(store2, "sessions.json") + blob <- jsonlite::fromJSON(paste(readLines(path, warn = FALSE), + collapse = "\n"), simplifyVector = FALSE) + blob$olm_in[[f$ak$curve25519]] <- blob$olm_in[[f$ak$curve25519]][[1L]] + writeLines(jsonlite::toJSON(blob, auto_unbox = TRUE), path) + reloaded <- mx_crypto_sessions_load(store2) + expect_true(is.list(reloaded$olm_in[[f$ak$curve25519]])) + expect_equal(length(reloaded$olm_in[[f$ak$curve25519]]), 1L) + legacy_msg <- share(f$out, f$ak, f$bk, alice_id, bob_id, "legacy reload") + legacy_res <- process(f, reloaded, list(legacy_msg), alice = FALSE) + expect_false(inherits(legacy_res, "error")) + if (!inherits(legacy_res, "error")) { + expect_equal(length(legacy_res$events), 1L) + } }) diff --git a/man/mx_call_key_parse.Rd b/man/mx_call_key_parse.Rd index eea6ed0..22d3f04 100644 --- a/man/mx_call_key_parse.Rd +++ b/man/mx_call_key_parse.Rd @@ -11,19 +11,22 @@ mx_call_key_parse(event, room_id) \item{room_id}{The call's room; keys for other rooms are ignored.} } \value{ -\code{list(identity, key, index)}, with \code{key} a raw - vector, or NULL when the event is not a usable key for this room. +A list of \code{list(identity, key, index)}, with \code{key} a + raw vector, one per usable key in the event; empty when the event is + not a usable key for this room. } \description{ Parses a decrypted \code{io.element.call.encryption_keys} to-device event (from the \code{to_device} list of -\code{\link{mx_crypto_process_sync}}) into the LiveKit identity it -belongs to and the key to set for it. +\code{\link{mx_crypto_process_sync}}) into the LiveKit identities and +keys to set. The event's \code{keys} field is an array, so one event +can carry several keys at different indices; each becomes one entry. } \examples{ ev <- list(type = "io.element.call.encryption_keys", sender = "@alice:example.org", - content = list(keys = list(index = 3, key = "AAECAwQFBgcICQoLDA0ODw=="), + content = list( + keys = list(list(index = 3, key = "AAECAwQFBgcICQoLDA0ODw==")), member = list(claimed_device_id = "PHONE"), room_id = "!r:example.org")) mx_call_key_parse(ev, "!r:example.org") } diff --git a/man/mx_crypto_process_sync.Rd b/man/mx_crypto_process_sync.Rd index 2dce28a..1e1ef6e 100644 --- a/man/mx_crypto_process_sync.Rd +++ b/man/mx_crypto_process_sync.Rd @@ -40,7 +40,9 @@ local \code{self_master_key}; they never verify the original sender.} \code{self_id} are required to create room-key requests.} } \value{ -List with \code{events} (decrypted, normalized), updated +List with \code{events} (decrypted, normalized; each also + carries its inner \code{type} and \code{content}, so non-message + room events such as call encryption keys are usable), 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 diff --git a/man/mx_send_encrypted.Rd b/man/mx_send_encrypted.Rd index c17115d..b4768ae 100644 --- a/man/mx_send_encrypted.Rd +++ b/man/mx_send_encrypted.Rd @@ -11,7 +11,8 @@ mx_send_encrypted( content, store_dir, recipients = NULL, - member_ids = NULL + member_ids = NULL, + event_type = "m.room.message" ) } \arguments{ @@ -36,6 +37,9 @@ a room whose other members have no devices is a real room.} \item{member_ids}{Character vector of room member user ids (used when \code{recipients} is NULL).} + +\item{event_type}{Inner Matrix event type, defaulting to +\code{m.room.message}.} } \value{ List with \code{event_id} and the updated \code{sessions}.