diff --git a/.github/check-results.R b/.github/check-results.R new file mode 100644 index 0000000..d71069f --- /dev/null +++ b/.github/check-results.R @@ -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) + } +} diff --git a/.github/install-matrix-deps.R b/.github/install-matrix-deps.R new file mode 100644 index 0000000..8546e64 --- /dev/null +++ b/.github/install-matrix-deps.R @@ -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")) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c5f7da9..563990b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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 @@ -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 diff --git a/DESCRIPTION b/DESCRIPTION index 3234418..d1c5e24 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -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", diff --git a/NAMESPACE b/NAMESPACE index 4ca1533..c96d777 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -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) @@ -41,6 +43,17 @@ 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) @@ -48,6 +61,7 @@ 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) diff --git a/NEWS.md b/NEWS.md index f25e087..4a438d9 100644 --- a/NEWS.md +++ b/NEWS.md @@ -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 diff --git a/R/crypto.R b/R/crypto.R index ed60391..36b02b0 100644 --- a/R/crypto.R +++ b/R/crypto.R @@ -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{ @@ -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) diff --git a/R/e2ee.R b/R/e2ee.R index 414cae4..bd0449c 100644 --- a/R/e2ee.R +++ b/R/e2ee.R @@ -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 @@ -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 ", @@ -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) } @@ -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. @@ -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 @@ -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 @@ -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 @@ -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 @@ -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, @@ -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) diff --git a/R/identity-trust.R b/R/identity-trust.R new file mode 100644 index 0000000..2e82c89 --- /dev/null +++ b/R/identity-trust.R @@ -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 +} diff --git a/R/sas-console.R b/R/sas-console.R new file mode 100644 index 0000000..faf45b0 --- /dev/null +++ b/R/sas-console.R @@ -0,0 +1,101 @@ +sas_is_event <- function(event) { + is.list(event) && is.list(event$content) && sas_scalar(event$type) && + (startsWith(event$type, "m.key.verification.") || + (identical(event$type, "m.room.message") && + identical(event$content$msgtype, "m.key.verification.request"))) +} + +sas_console_read <- function(prompt) { + if (!interactive()) { + stop("mx.client: verification needs an interactive human console", + call. = FALSE) + } + readline(prompt) +} + +#' Compare a Matrix SAS through a trusted interactive console +#' +#' Drives one transaction using the application's existing event consumer. +#' No second sync loop is created by this function. The human must explicitly +#' type yes after comparing all seven emoji or all three numbers with the peer. +#' Empty input, no, or cancellation grants no trust. Do not connect the input +#' callback to an LLM or a Matrix room. +#' +#' @param sas An in-memory transaction from mx_sas_from_request(). +#' @param receive Function with no arguments returning a list of original +#' Matrix events from the sole event consumer. It should wait briefly. +#' @param send Function accepting one outgoing envelope. It must throw on +#' failure and use the envelope's id for idempotent retries. +#' @param complete Function accepting sas, recording and checking durable trust +#' with mx_sas_record_trust(), outside the crypto commit window. +#' @param input Function taking a prompt. The default requires an interactive +#' console; replacement is intended for a trusted human UI or isolated tests. +#' @return A status list, invisibly. Interrupts cancel and attempt notification. +#' @export +mx_sas_console <- function(sas, receive, send, complete, input = sas_console_read) { + sas_check(sas) + if (!all(vapply(list(receive, send, complete, input), is.function, logical(1)))) { + stop("mx.client: console callbacks must be functions", call. = FALSE) + } + flush <- function() { + for (event in mx_sas_outgoing(sas)) { + send(event) + mx_sas_outgoing(sas, event$id) + } + } + on.exit({ + if (!sas_terminal(sas)) mx_sas_cancel(sas) + tryCatch(flush(), error = function(e) warning( + "mx.client: verification notification failed: ", conditionMessage(e), + call. = FALSE)) + }, add = TRUE) + message("Verification with ", sas$peer_user_id, " / ", sas$peer_device_id) + if (identical(sas$phase, "requested") && !sas$initiator) { + answer <- input("Accept this verification request? Type yes: ") + # Process cancellations that arrived while the human was reading. + for (event in receive()) mx_sas_receive(sas, event) + if (identical(answer, "yes")) { + if (!sas_terminal(sas)) mx_sas_accept(sas) + } else mx_sas_cancel(sas) + } + repeat { + mx_sas_receive(sas) + flush() + if (sas_terminal(sas)) break + if (identical(sas$phase, "sas") && !sas$confirmed) { + status <- mx_sas_status(sas) + message("Compare using the other device or a trusted independent channel.") + if (length(status$emoji)) { + message(paste(status$emoji, collapse = " ")) + message(paste(status$descriptions, collapse = " | ")) + } + if (length(status$decimal)) message(paste(status$decimal, collapse = " - ")) + answer <- input("Do all emoji or all numbers match? Type yes: ") + for (event in receive()) mx_sas_receive(sas, event) + if (!sas_terminal(sas)) mx_sas_confirm(sas, identical(answer, "yes")) + next + } + if (identical(sas$phase, "verified") && !sas$local_trust_recorded) { + complete(sas) + if (!isTRUE(sas$local_trust_recorded)) { + stop("mx.client: completion did not record and check local trust", + call. = FALSE) + } + next + } + for (event in receive()) mx_sas_receive(sas, event) + } + status <- mx_sas_status(sas) + if (identical(status$phase, "done")) { + message("Local trust recorded; peer acknowledged verification completion.") + } else { + message("Verification cancelled: ", status$cancel_code, + if (status$local_trust_recorded) "; local trust was already recorded" else "") + if (identical(status$cancel_detail, "peer_master_missing")) { + message("The peer did not authenticate its master key. Restore or ", + "verify your own cryptographic identity in the peer app, then ", + "start a new verification. No peer identity trust was recorded.") + } + } + invisible(status) +} diff --git a/R/sas-display.R b/R/sas-display.R new file mode 100644 index 0000000..436f660 --- /dev/null +++ b/R/sas-display.R @@ -0,0 +1,70 @@ +# Matrix's fixed SAS alphabet. Escapes keep the R source ASCII-only. +sas_emoji <- function() { + codepoints <- c(0x1f436, 0x1f431, 0x1f981, 0x1f40e, 0x1f984, 0x1f437, + 0x1f418, 0x1f430, 0x1f43c, 0x1f413, 0x1f427, 0x1f422, 0x1f41f, + 0x1f419, 0x1f98b, 0x1f337, 0x1f333, 0x1f335, 0x1f344, 0x1f30f, + 0x1f319, 0x2601, 0x1f525, 0x1f34c, 0x1f34e, 0x1f353, 0x1f33d, + 0x1f355, 0x1f382, 0x2764, 0x1f600, 0x1f916, 0x1f3a9, 0x1f453, + 0x1f527, 0x1f385, 0x1f44d, 0x2602, 0x231b, 0x23f0, 0x1f381, + 0x1f4a1, 0x1f4d5, 0x270f, 0x1f4ce, 0x2702, 0x1f512, 0x1f511, + 0x1f528, 0x260e, 0x1f3c1, 0x1f682, 0x1f6b2, 0x2708, 0x1f680, + 0x1f3c6, 0x26bd, 0x1f3b8, 0x1f3ba, 0x1f514, 0x2693, 0x1f3a7, + 0x1f4c1, 0x1f4cc) + emoji <- intToUtf8(codepoints, multiple = TRUE) + variation <- c(22L, 30L, 38L, 44L, 46L, 50L, 54L) + emoji[variation] <- paste0(emoji[variation], "\ufe0f") + labels <- c("Dog", "Cat", "Lion", "Horse", "Unicorn", "Pig", "Elephant", + "Rabbit", "Panda", "Rooster", "Penguin", "Turtle", "Fish", "Octopus", + "Butterfly", "Flower", "Tree", "Cactus", "Mushroom", "Globe", "Moon", + "Cloud", "Fire", "Banana", "Apple", "Strawberry", "Corn", "Pizza", + "Cake", "Heart", "Smiley", "Robot", "Hat", "Glasses", "Spanner", + "Santa", "Thumbs Up", "Umbrella", "Hourglass", "Clock", "Gift", + "Light Bulb", "Book", "Pencil", "Paperclip", "Scissors", "Lock", "Key", + "Hammer", "Telephone", "Flag", "Train", "Bicycle", "Aeroplane", + "Rocket", "Trophy", "Ball", "Guitar", "Trumpet", "Bell", "Anchor", + "Headphones", "Folder", "Pin") + list(emoji = emoji, labels = labels) +} + +sas_display <- function(bytes) { + b <- as.integer(bytes) + stopifnot(length(b) == 6L, all(b >= 0L & b <= 255L)) + decimal <- c(b[1] * 32L + b[2] %/% 8L, + (b[2] %% 8L) * 1024L + b[3] * 4L + b[4] %/% 64L, + (b[4] %% 64L) * 128L + b[5] %/% 2L) + 1000L + indexes <- c(b[1] %/% 4L, (b[1] %% 4L) * 16L + b[2] %/% 16L, + (b[2] %% 16L) * 4L + b[3] %/% 64L, b[3] %% 64L, + b[4] %/% 4L, (b[4] %% 4L) * 16L + b[5] %/% 16L, + (b[5] %% 16L) * 4L + b[6] %/% 64L) + 1L + alphabet <- sas_emoji() + list(decimal = decimal, emoji = alphabet$emoji[indexes], + descriptions = alphabet$labels[indexes]) +} + +#' Inspect a Matrix SAS verification transaction +#' +#' Display codes only on the operator's trusted console, never in the Matrix +#' conversation being verified. A verified SAS is distinct from a recorded +#' local trust signature and from the peer's completion acknowledgement. +#' @param sas An in-memory SAS transaction. +#' @return A list with identities, phase, comparison values, local confirmation, +#' peer MAC validity, local trust status, peer completion, and cancellation code. +#' cancel_detail identifies a locally diagnosed missing peer master proof. +#' @export +mx_sas_status <- function(sas) { + sas_check(sas) + display <- if (sas$phase %in% c("sas", "confirmed", "verified", "done")) { + sas_display(sas$display_bytes) + } else list() + if (!"decimal" %in% sas$displays) display$decimal <- NULL + if (!"emoji" %in% sas$displays) { + display$emoji <- display$descriptions <- NULL + } + c(list(user_id = sas$user_id, device_id = sas$device_id, + peer_user_id = sas$peer_user_id, peer_device_id = sas$peer_device_id, + transaction_id = sas$transaction_id, phase = sas$phase), display, + list(confirmed = sas$confirmed, peer_mac_valid = sas$peer_mac_valid, + local_trust_recorded = sas$local_trust_recorded, + peer_done = sas$peer_done, cancel_code = sas$cancel_code, + cancel_detail = sas$cancel_detail)) +} diff --git a/R/sas-identity.R b/R/sas-identity.R new file mode 100644 index 0000000..7592013 --- /dev/null +++ b/R/sas-identity.R @@ -0,0 +1,164 @@ +# Read existing identity material only. Verification must not create an account. +sas_identity <- function(client, store_dir, peer_user_id, peer_device_id) { + required <- file.path(store_dir, c("pickle.key", "account.pickle", + "cross-signing.json")) + if (!all(file.exists(required)) || file.info(required[1])$size != 32L) { + stop("mx.client: SAS requires the device's existing crypto store; ", + "no identity was created", call. = FALSE) + } + signing <- mx_crypto_cross_signing_load(store_dir) + master <- mx.crypto::mxc_signing_key_public(signing$master) + account <- mx_crypto_account(store_dir) + local_keys <- mx.crypto::mxc_account_identity_keys(account) + users <- unique(c(client$user_id, peer_user_id)) + result <- mx.api::mx_keys_query(mx_client_session(client), + stats::setNames(rep(list(list()), length(users)), users)) + mx_crypto_report_failures(result$failures, "/keys/query", strict = TRUE) + published <- mx_crypto_cross_signing_public( + result$master_keys[[client$user_id]], client$user_id, "master") + if (!identical(master, published)) { + stop("mx.client: local and homeserver master keys differ", call. = FALSE) + } + published_self <- result$self_signing_keys[[client$user_id]] + local_self <- mx.crypto::mxc_signing_key_public(signing$self_signing) + if (!identical(mx_crypto_cross_signing_public(published_self, + client$user_id, "self_signing"), local_self) || + !mx_crypto_signature_valid(published_self, client$user_id, master)) { + stop("mx.client: self-signing key does not match the local identity", + call. = FALSE) + } + our_device <- result$device_keys[[client$user_id]][[client$device_id]] + own <- mx.crypto::mxc_verify_device_keys(our_device, + client$user_id, client$device_id) + if (!identical(own$ed25519, local_keys$ed25519) || + !identical(own$curve25519, local_keys$curve25519)) { + stop("mx.client: local and published device keys differ", call. = FALSE) + } + peer_master <- mx_crypto_cross_signing_public( + result$master_keys[[peer_user_id]], peer_user_id, "master") + peer_device <- result$device_keys[[peer_user_id]][[peer_device_id]] + peer <- mx.crypto::mxc_verify_device_keys(peer_device, + peer_user_id, peer_device_id) + make_keys <- function(device, device_key, master_key) { + sas_keys(stats::setNames(list(device_key, master_key), + paste0("ed25519:", c(device, master_key))), device) + } + # Preflight the signing authority before inviting the human to compare. + if (!identical(client$user_id, peer_user_id)) { + mx_crypto_user_verification_context(client, store_dir, + peer_user_id, peer_master) + } else if (!identical(peer_master, master)) { + stop("mx.client: another device claims a different master key", call. = FALSE) + } + list(keys = make_keys(client$device_id, own$ed25519, master), + peer_keys = make_keys(peer_device_id, peer$ed25519, peer_master), + signing = signing, peer_device = peer_device) +} + +#' Create a SAS transaction from an incoming verification request +#' +#' Validates the request's age, recipient, device and method, then reads a +#' fixed key snapshot. No sync, transport, new identity, or trust upload occurs. +#' Requests are accepted only when the operator calls mx_sas_accept(). +#' @param client This device's Matrix client config. +#' @param store_dir This device's existing crypto store. +#' @param event Original request envelope from the existing event consumer. +#' @param now Current time. +#' @return An in-memory SAS transaction, or NULL for an irrelevant/expired request. +#' @export +mx_sas_from_request <- function(client, store_dir, event, now = Sys.time()) { + if (length(now) != 1L || !is.finite(as.numeric(now))) { + stop("mx.client: now must be one finite time", call. = FALSE) + } + if (!is.list(event) || !is.list(event$content)) return(NULL) + c <- event$content + room <- event$room_id + in_room <- !is.null(room) + request <- if (in_room) identical(event$type, "m.room.message") && + identical(c$msgtype, "m.key.verification.request") && + identical(c$to, client$user_id) else + identical(event$type, "m.key.verification.request") + id <- if (in_room) event$event_id else c$transaction_id + timestamp <- if (in_room) event$origin_server_ts else c$timestamp + valid <- request && sas_scalar(event$sender) && sas_scalar(c$from_device) && + sas_scalar(id) && sas_agreed(c, "methods", "m.sas.v1") && + is.numeric(timestamp) && length(timestamp) == 1L && is.finite(timestamp) && + timestamp / 1000 > as.numeric(now) - 600 && + timestamp / 1000 <= as.numeric(now) + 300 && + !(identical(event$sender, client$user_id) && + identical(c$from_device, client$device_id)) + if (!valid) return(NULL) + sas_require_crypto() + snapshot <- sas_identity(client, store_dir, event$sender, c$from_device) + sas <- mx_sas_session(client$user_id, client$device_id, snapshot$keys, + event$sender, c$from_device, snapshot$peer_keys, id, room, now = now) + sas$created <- min(as.numeric(now), timestamp / 1000) + sas +} + +#' Record trust after a human-confirmed, authenticated SAS exchange +#' +#' Rechecks the fixed identity snapshot, signs the peer master with this +#' user's user-signing key (or its own peer device with its self-signing key), +#' and verifies server read-back. Only then is done queued. A peer's done +#' acknowledgement does not expose or prove its private user-signing key. +#' @param sas A SAS transaction with both human confirmation and valid peer MACs. +#' @param client This device's Matrix client config. +#' @param store_dir This device's existing cross-signing store. +#' @param now Current time. +#' @return The transaction, invisibly. Errors leave completion retryable. +#' @export +mx_sas_record_trust <- function(sas, client, store_dir, now = Sys.time()) { + sas_check(sas) + if (!identical(client$user_id, sas$user_id) || + !identical(client$device_id, sas$device_id)) { + stop("mx.client: SAS belongs to a different device", call. = FALSE) + } + if (isTRUE(sas$local_trust_recorded)) return(invisible(sas)) + if (sas_expire(sas, now) || !identical(sas$phase, "verified") || + !isTRUE(sas$confirmed) || !isTRUE(sas$peer_mac_valid)) { + stop("mx.client: human confirmation and valid peer MACs are required", + call. = FALSE) + } + current <- sas_identity(client, store_dir, sas$peer_user_id, sas$peer_device_id) + if (!identical(current$keys, sas$keys) || + !identical(current$peer_keys, sas$peer_keys)) { + mx_sas_cancel(sas, "m.key_mismatch") + stop("mx.client: identity keys changed during SAS verification", call. = FALSE) + } + if (!identical(sas$user_id, sas$peer_user_id)) { + master <- sas$peer_keys[[setdiff(names(sas$peer_keys), + paste0("ed25519:", sas$peer_device_id))]] + mx_crypto_verify_user(client, store_dir, sas$peer_user_id, master) + } else { + key <- current$signing$self_signing + public <- mx.crypto::mxc_signing_key_public(key) + if (!mx_crypto_signature_valid(current$peer_device, sas$user_id, public)) { + object <- current$peer_device + object$signatures <- object$unsigned <- NULL + signed <- mx_crypto_add_signature(object, key, sas$user_id, + paste0("ed25519:", public)) + response <- mx.api::mx_keys_signatures_upload(mx_client_session(client), + stats::setNames(list(stats::setNames(list(signed), + sas$peer_device_id)), sas$user_id)) + mx_crypto_report_failures(response$failures, "SAS signature upload", + strict = TRUE) + current <- sas_identity(client, store_dir, sas$peer_user_id, + sas$peer_device_id) + } + if (!mx_crypto_signature_valid(current$peer_device, sas$user_id, public)) { + stop("mx.client: device trust signature was not confirmed by read-back", + call. = FALSE) + } + if (!identical(current$keys, sas$keys) || + !identical(current$peer_keys, sas$peer_keys)) { + mx_sas_cancel(sas, "m.key_mismatch") + stop("mx.client: device keys changed during trust read-back", + call. = FALSE) + } + } + sas$local_trust_recorded <- TRUE + sas_queue(sas, "done", list()) + if (sas$peer_done) sas$phase <- "done" + invisible(sas) +} diff --git a/R/sas-receive.R b/R/sas-receive.R new file mode 100644 index 0000000..f48297c --- /dev/null +++ b/R/sas-receive.R @@ -0,0 +1,235 @@ +sas_commitment <- function(public, start) { + mx.crypto::mxc_sas_commitment(public, mx.api::mx_canonical_json(start)) +} + +sas_agreed <- function(content, field, wanted) { + values <- unlist(content[[field]], use.names = FALSE) + is.character(values) && !anyNA(values) && wanted %in% values +} + +sas_info <- function(sas, mac = FALSE, peer = FALSE) { + ours <- c(sas$user_id, sas$device_id) + theirs <- c(sas$peer_user_id, sas$peer_device_id) + if (mac) { + sides <- if (peer) c(theirs, ours) else c(ours, theirs) + return(paste0("MATRIX_KEY_VERIFICATION_MAC", + paste0(sides, collapse = ""), sas$transaction_id)) + } + ours <- c(ours, mx.crypto::mxc_sas_public(sas$crypto)) + theirs <- c(theirs, sas$peer_ephemeral) + sides <- if (sas$starter) c(ours, theirs) else c(theirs, ours) + paste(c("MATRIX_KEY_VERIFICATION_SAS", sides, sas$transaction_id), collapse = "|") +} + +sas_receive_start <- function(sas, content) { + if (identical(sas$phase, "started")) { + # Resolve simultaneous starts using byte ordering, not the R locale. + ids <- if (identical(sas$user_id, sas$peer_user_id)) { + c(sas$device_id, sas$peer_device_id) + } else c(sas$user_id, sas$peer_user_id) + if (order(ids, method = "radix")[[1L]] == 1L) return(invisible(NULL)) + sas$outbox <- Filter(function(x) x$type != "m.key.verification.start", sas$outbox) + } else if (!identical(sas$phase, "ready")) { + return(mx_sas_cancel(sas, "m.unexpected_message")) + } + valid <- identical(content$method, "m.sas.v1") && + sas_agreed(content, "key_agreement_protocols", "curve25519-hkdf-sha256") && + sas_agreed(content, "hashes", "sha256") && + sas_agreed(content, "message_authentication_codes", "hkdf-hmac-sha256.v2") + displays <- intersect(c("decimal", "emoji"), + unlist(content$short_authentication_string, use.names = FALSE)) + if (!valid || !length(displays)) return(mx_sas_cancel(sas, "m.unknown_method")) + sas$start <- content + sas$starter <- FALSE + sas$crypto <- mx.crypto::mxc_sas_new() + sas$displays <- displays + commitment <- sas_commitment(mx.crypto::mxc_sas_public(sas$crypto), content) + sas$phase <- "accepted" + sas_queue(sas, "accept", list(method = "m.sas.v1", + key_agreement_protocol = "curve25519-hkdf-sha256", hash = "sha256", + message_authentication_code = "hkdf-hmac-sha256.v2", + short_authentication_string = as.list(displays), commitment = commitment)) +} + +sas_receive_accept <- function(sas, content) { + if (!identical(sas$phase, "started")) { + return(mx_sas_cancel(sas, "m.unexpected_message")) + } + displays <- unlist(content$short_authentication_string, use.names = FALSE) + valid <- identical(content$method, "m.sas.v1") && + identical(content$key_agreement_protocol, "curve25519-hkdf-sha256") && + identical(content$hash, "sha256") && + identical(content$message_authentication_code, "hkdf-hmac-sha256.v2") && + is.character(displays) && length(displays) > 0L && !anyNA(displays) && + all(displays %in% c("decimal", "emoji")) && + sas_scalar(content$commitment) && + grepl("^[A-Za-z0-9+/]{43}$", content$commitment) + if (!valid) return(mx_sas_cancel(sas, "m.unknown_method")) + sas$commitment <- content$commitment + sas$displays <- displays + sas$phase <- "key_sent" + sas_queue(sas, "key", list(key = mx.crypto::mxc_sas_public(sas$crypto))) +} + +sas_receive_key <- function(sas, content) { + if (!sas$phase %in% c("accepted", "key_sent")) { + return(mx_sas_cancel(sas, "m.unexpected_message")) + } + if (!sas_scalar(content$key) || !grepl("^[A-Za-z0-9+/]{43}$", content$key)) { + return(mx_sas_cancel(sas, "m.invalid_message")) + } + if (sas$starter && !identical(sas$commitment, + sas_commitment(content$key, sas$start))) { + return(mx_sas_cancel(sas, "m.mismatched_commitment")) + } + mx.crypto::mxc_sas_establish(sas$crypto, content$key) + sas$peer_ephemeral <- content$key + if (!sas$starter) { + sas_queue(sas, "key", list(key = mx.crypto::mxc_sas_public(sas$crypto))) + } + sas$display_bytes <- mx.crypto::mxc_sas_bytes(sas$crypto, sas_info(sas)) + sas$phase <- "sas" +} + +sas_receive_mac <- function(sas, content) { + if (!sas$phase %in% c("sas", "confirmed")) { + return(mx_sas_cancel(sas, "m.unexpected_message")) + } + tags <- content$mac + required <- paste0("ed25519:", sas$peer_device_id) + if (!is.list(tags) || is.null(names(tags)) || anyDuplicated(names(tags)) || + !all(required %in% names(tags)) || !all(names(tags) %in% names(sas$peer_keys)) || + !all(vapply(tags, sas_scalar, logical(1))) || !sas_scalar(content$keys)) { + return(mx_sas_cancel(sas, "m.key_mismatch")) + } + ids <- sort(names(tags), method = "radix") + info <- sas_info(sas, mac = TRUE, peer = TRUE) + valid <- mx.crypto::mxc_sas_verify_mac(sas$crypto, paste(ids, collapse = ","), + paste0(info, "KEY_IDS"), content$keys) + for (id in ids) { + valid <- valid && mx.crypto::mxc_sas_verify_mac(sas$crypto, + sas$peer_keys[[id]], paste0(info, id), tags[[id]]) + } + if (!valid) return(mx_sas_cancel(sas, "m.key_mismatch")) + # A valid device-only MAC is not a proof of another user's master. + # Keep that requirement, but distinguish omission from a corrupt MAC. + # Our own new device needs only its device proof under our local pin. + if (!identical(sas$user_id, sas$peer_user_id) && + !all(names(sas$peer_keys) %in% ids)) { + sas$cancel_detail <- "peer_master_missing" + return(mx_sas_cancel(sas, "m.key_mismatch")) + } + sas$peer_mac_valid <- TRUE + if (sas$confirmed) sas$phase <- "verified" +} + +#' Receive one standard Matrix SAS protocol event +#' +#' Ignores other senders, rooms, devices, and transaction ids. Invalid +#' messages in this transaction cancel it. Duplicate identical messages do +#' not repeat operations; conflicting repeats cancel. NULL checks timeouts. +#' Feed decrypted original event type and content, not flattened chat text. +#' No transport or persistent trust operation occurs here. +#' @param sas An in-memory SAS transaction. +#' @param event A Matrix event envelope with type, sender, content, and +#' room_id for in-room verification, or NULL. +#' @param now Current time. +#' @return The transaction, invisibly. +#' @export +mx_sas_receive <- function(sas, event = NULL, now = Sys.time()) { + sas_check(sas) + if (sas_expire(sas, now) || is.null(event)) return(invisible(sas)) + if (!is.list(event) || !identical(event$sender, sas$peer_user_id) || + !identical(event$room_id, sas$room_id) || !is.list(event$content)) { + return(invisible(sas)) + } + content <- event$content + id <- if (is.null(sas$room_id)) content$transaction_id else { + relation <- content$`m.relates_to` + if (!is.list(relation) || !identical(relation$rel_type, "m.reference")) + NULL else relation$event_id + } + if (!identical(id, sas$transaction_id) || + (!is.null(content$from_device) && + !identical(content$from_device, sas$peer_device_id))) return(invisible(sas)) + type <- event$type + if (!sas_scalar(type) || !startsWith(type, "m.key.verification.")) { + return(invisible(sas)) + } + tryCatch({ + canonical <- mx.api::mx_canonical_json(content) + previous <- sas$seen[[type]] + if (!is.null(previous)) { + if (!identical(previous, canonical)) mx_sas_cancel(sas, "m.unexpected_message") + return(invisible(sas)) + } + sas$active <- as.numeric(now) + if (identical(type, "m.key.verification.cancel")) { + sas$phase <- "cancelled" + sas$cancel_code <- if (sas_scalar(content$code)) content$code else "m.invalid_message" + sas$crypto <- NULL + sas$outbox <- list() + } else if (identical(type, "m.key.verification.ready")) { + if (!identical(sas$phase, "requested") || !sas$initiator || + !identical(content$from_device, sas$peer_device_id) || + !sas_agreed(content, "methods", "m.sas.v1")) { + mx_sas_cancel(sas, "m.unexpected_message") + } else { + sas$phase <- "ready" + mx_sas_start(sas, now) + } + } else if (identical(type, "m.key.verification.start")) { + if (!identical(content$from_device, sas$peer_device_id)) { + mx_sas_cancel(sas, "m.invalid_message") + } else sas_receive_start(sas, content) + } else if (identical(type, "m.key.verification.accept")) { + sas_receive_accept(sas, content) + } else if (identical(type, "m.key.verification.key")) { + sas_receive_key(sas, content) + } else if (identical(type, "m.key.verification.mac")) { + sas_receive_mac(sas, content) + } else if (identical(type, "m.key.verification.done")) { + if (!sas$peer_mac_valid) mx_sas_cancel(sas, "m.unexpected_message") else { + sas$peer_done <- TRUE + if (sas$local_trust_recorded) sas$phase <- "done" + } + } else mx_sas_cancel(sas, "m.unknown_method") + sas$seen[[type]] <- canonical + }, error = function(e) mx_sas_cancel(sas, "m.invalid_message")) + invisible(sas) +} + +#' Confirm or reject the displayed Matrix SAS +#' +#' Call only after the human compares the display through a trusted channel. +#' TRUE queues MACs for this device and its master key. Both a valid peer MAC +#' and local confirmation are required before the phase becomes verified. +#' This alone does not upload cross-signing signatures. +#' @param sas An in-memory SAS transaction displaying a SAS. +#' @param matches One explicit TRUE or FALSE, without a default. +#' @param now Current time. +#' @return The transaction, invisibly. +#' @export +mx_sas_confirm <- function(sas, matches, now = Sys.time()) { + sas_check(sas) + if (sas_expire(sas, now)) return(invisible(sas)) + if (!is.logical(matches) || length(matches) != 1L || is.na(matches)) { + stop("mx.client: explicitly confirm TRUE or reject FALSE", call. = FALSE) + } + if (!identical(sas$phase, "sas")) { + stop("mx.client: no SAS is awaiting confirmation", call. = FALSE) + } + if (!matches) return(mx_sas_cancel(sas, "m.mismatched_sas")) + ids <- sort(names(sas$keys), method = "radix") + info <- sas_info(sas, mac = TRUE) + tags <- lapply(ids, function(id) mx.crypto::mxc_sas_mac(sas$crypto, + sas$keys[[id]], paste0(info, id))) + names(tags) <- ids + sas_queue(sas, "mac", list(mac = tags, + keys = mx.crypto::mxc_sas_mac(sas$crypto, paste(ids, collapse = ","), + paste0(info, "KEY_IDS")))) + sas$confirmed <- TRUE + sas$active <- as.numeric(now) + sas$phase <- if (sas$peer_mac_valid) "verified" else "confirmed" + invisible(sas) +} diff --git a/R/sas-session.R b/R/sas-session.R new file mode 100644 index 0000000..51e7938 --- /dev/null +++ b/R/sas-session.R @@ -0,0 +1,211 @@ +# Protocol state contains ephemeral keys, never an account or ratchet writer. +# Transport and durable trust uploads are explicit operations outside it. + +sas_require_crypto <- function() { + mx_require_crypto() + if (!"mxc_sas_commitment" %in% getNamespaceExports("mx.crypto")) { + stop("mx.client: SAS requires mx.crypto >= 0.2.1.2 with SAS primitives", + call. = FALSE) + } + invisible(NULL) +} + +sas_scalar <- function(x) is.character(x) && length(x) == 1L && + !is.na(x) && nzchar(x) && !grepl("[[:cntrl:]]", x) + +sas_keys <- function(keys, device_id) { + if (!is.list(keys) || length(keys) != 2L || is.null(names(keys)) || + anyDuplicated(names(keys)) || + !all(vapply(keys, function(x) sas_scalar(x) && + grepl("^[A-Za-z0-9+/]{43}$", x), logical(1)))) { + stop("mx.client: SAS requires a device key and a master key", call. = FALSE) + } + device <- paste0("ed25519:", device_id) + other <- setdiff(names(keys), device) + if (!device %in% names(keys) || length(other) != 1L || + !identical(other, paste0("ed25519:", keys[[other]]))) { + stop("mx.client: invalid or colliding SAS key identifiers", call. = FALSE) + } + keys +} + +sas_check <- function(sas) { + if (!inherits(sas, "mx_sas") || !is.environment(sas)) { + stop("mx.client: expected a SAS transaction", call. = FALSE) + } +} + +sas_wire <- function(sas, content) { + content$from_device <- sas$device_id + if (is.null(sas$room_id)) { + content$transaction_id <- sas$transaction_id + } else { + content$`m.relates_to` <- list(rel_type = "m.reference", + event_id = sas$transaction_id) + } + content +} + +sas_queue <- function(sas, type, content) { + sas$serial <- sas$serial + 1L + id <- paste0(sas$transport_id, "-", sas$serial) + sas$outbox[[id]] <- list(id = id, user_id = sas$peer_user_id, + device_id = sas$peer_device_id, room_id = sas$room_id, + type = paste0("m.key.verification.", type), + content = sas_wire(sas, content)) + invisible(NULL) +} + +sas_terminal <- function(sas) sas$phase %in% c("cancelled", "done") + +sas_expire <- function(sas, now) { + if (length(now) != 1L || !is.finite(as.numeric(now))) { + stop("mx.client: now must be one finite time", call. = FALSE) + } + now <- as.numeric(now) + idle <- if (identical(sas$phase, "requested")) 120 else 600 + if (!sas_terminal(sas) && + (now >= sas$created + 600 || now >= sas$active + idle)) { + mx_sas_cancel(sas, "m.timeout") + } + sas_terminal(sas) +} + +#' Create an in-memory Matrix SAS transaction +#' +#' Holds a fixed snapshot of both parties' device and master public keys. +#' Feed this object events from the application's existing event consumer; +#' this function does not start sync, send messages, or record trust. +#' Ephemeral state cannot be persisted. Restart interrupted transactions +#' with a new request id and new session. Only modern SAS algorithms are used. +#' +#' @param user_id This account's Matrix user id. +#' @param device_id This account's device id. +#' @param keys Named list of this device's Ed25519 key and locally trusted +#' master key. Names are ed25519:device_id and ed25519:master_public_key. +#' @param peer_user_id The selected peer user id. +#' @param peer_device_id The selected peer device id. +#' @param peer_keys Named list containing the peer device and master public +#' keys fetched and validated before the handshake. SAS authenticates this +#' exact snapshot, not later replacements from a homeserver. +#' @param transaction_id Initial request event id for room verification, +#' or the initial to-device request's transaction_id. +#' @param room_id Room id, or NULL for to-device verification. +#' @param initiator TRUE if this device sent the initial request. +#' @param now Current time. Injectable for deterministic timeout tests. +#' @return An opaque in-memory transaction. Use mx_sas_status() for display. +#' @export +mx_sas_session <- function(user_id, device_id, keys, peer_user_id, + peer_device_id, peer_keys, transaction_id, room_id = NULL, + initiator = FALSE, now = Sys.time()) { + sas_require_crypto() + fields <- list(user_id, device_id, peer_user_id, peer_device_id, transaction_id) + if (!all(vapply(fields, sas_scalar, logical(1))) || + !startsWith(user_id, "@") || !startsWith(peer_user_id, "@") || + (!is.null(room_id) && !sas_scalar(room_id)) || + (identical(user_id, peer_user_id) && identical(device_id, peer_device_id)) || + !is.logical(initiator) || length(initiator) != 1L || is.na(initiator) || + length(now) != 1L || !is.finite(as.numeric(now))) { + stop("mx.client: invalid SAS transaction identity", call. = FALSE) + } + sas <- new.env(parent = emptyenv()) + class(sas) <- "mx_sas" + sas$user_id <- user_id + sas$device_id <- device_id + sas$keys <- sas_keys(keys, device_id) + sas$peer_user_id <- peer_user_id + sas$peer_device_id <- peer_device_id + sas$peer_keys <- sas_keys(peer_keys, peer_device_id) + sas$transaction_id <- transaction_id + # Peer transaction ids are scoped to those devices, whereas HTTP retry + # ids share our access-token scope. Use an independent CSPRNG nonce. + sas$transport_id <- paste0("sas-", + mx.crypto::mxc_sas_public(mx.crypto::mxc_sas_new())) + sas$room_id <- room_id + sas$initiator <- initiator + sas$created <- sas$active <- as.numeric(now) + sas$phase <- "requested" + sas$outbox <- sas$seen <- list() + sas$serial <- 0L + sas$confirmed <- sas$peer_mac_valid <- sas$peer_done <- FALSE + sas$local_trust_recorded <- FALSE + sas +} + +#' Accept an incoming SAS verification request +#' @param sas An in-memory SAS transaction. +#' @param now Current time. +#' @return The transaction, invisibly. A ready event is queued, not sent. +#' @export +mx_sas_accept <- function(sas, now = Sys.time()) { + sas_check(sas) + if (sas_expire(sas, now)) return(invisible(sas)) + if (!identical(sas$phase, "requested") || sas$initiator) { + stop("mx.client: no incoming SAS request to accept", call. = FALSE) + } + sas$phase <- "ready" + sas$active <- as.numeric(now) + sas_queue(sas, "ready", list(methods = list("m.sas.v1"))) + invisible(sas) +} + +#' Begin the SAS key agreement after request negotiation +#' @param sas An in-memory SAS transaction in the ready phase. +#' @param now Current time. +#' @return The transaction, invisibly. A start event is queued, not sent. +#' @export +mx_sas_start <- function(sas, now = Sys.time()) { + sas_check(sas) + if (sas_expire(sas, now)) return(invisible(sas)) + if (!identical(sas$phase, "ready")) { + stop("mx.client: SAS request is not ready", call. = FALSE) + } + sas$start <- sas_wire(sas, list(method = "m.sas.v1", + key_agreement_protocols = list("curve25519-hkdf-sha256"), + hashes = list("sha256"), + message_authentication_codes = list("hkdf-hmac-sha256.v2"), + short_authentication_string = list("decimal", "emoji"))) + sas$crypto <- mx.crypto::mxc_sas_new() + sas$starter <- TRUE + sas$phase <- "started" + sas$active <- as.numeric(now) + sas_queue(sas, "start", sas$start) + invisible(sas) +} + +#' Cancel a SAS transaction without granting trust +#' @param sas An in-memory SAS transaction. +#' @param code Matrix cancellation code. +#' @return The transaction, invisibly. At most one cancellation is queued. +#' @export +mx_sas_cancel <- function(sas, code = "m.user") { + sas_check(sas) + if (sas_terminal(sas)) return(invisible(sas)) + if (!sas_scalar(code)) stop("mx.client: invalid cancellation code", call. = FALSE) + sas$outbox <- list() + sas$phase <- "cancelled" + sas$cancel_code <- code + sas$crypto <- NULL + sas_queue(sas, "cancel", list(code = code, reason = code)) + invisible(sas) +} + +#' Read or acknowledge queued SAS protocol messages +#' +#' Send outside any cryptographic state commit window. Acknowledge an id +#' only after transport succeeds. Retries retain the same id and payload. +#' Never display SAS codes in the Matrix conversation being verified. +#' @param sas An in-memory SAS transaction. +#' @param acknowledge Character vector of successfully sent queue ids. +#' @return A list of pending envelopes, containing type, content, destination, +#' and a stable transport id. No private key material is included. +#' @export +mx_sas_outgoing <- function(sas, acknowledge = character()) { + sas_check(sas) + if (!is.character(acknowledge) || anyNA(acknowledge) || + !all(acknowledge %in% names(sas$outbox))) { + stop("mx.client: unknown SAS outgoing message id", call. = FALSE) + } + sas$outbox[acknowledge] <- NULL + unname(sas$outbox) +} diff --git a/R/transport.R b/R/transport.R index da62216..f29e3e2 100644 --- a/R/transport.R +++ b/R/transport.R @@ -77,7 +77,12 @@ mx_crypto_publish_keys <- function(client, account, store_dir, n_otks = 50L) { #' leaves this user's devices not cross-signed; other users' chains are #' checked for internal consistency only. #' @return List of verified devices, each \code{list(user_id, device_id, -#' curve25519, ed25519, cross_signed, master_key)}. \code{master_key} is +#' curve25519, ed25519, cross_signed, master_key, identity_verified)}. +#' \code{identity_verified} additionally requires the locally pinned master +#' for this user, or its authenticated user-signing signature on another +#' user's master, followed by that user's self-signing/device chain. This +#' metadata does not change recipient policy or the existing device-level +#' meaning of \code{sender_verified}. \code{master_key} is #' the server-reported key from a valid chain, even if it fails the pin. #' @examples #' \dontrun{ @@ -89,12 +94,18 @@ mx_crypto_known_devices <- function(client, user_ids, strict = FALSE, mx_require_crypto() s <- mx_client_session(client) query <- stats::setNames(rep(list(list()), length(user_ids)), user_ids) + # Our user-signing key is needed even when only peers were requested. + if (!is.null(self_master_key)) { + query[[client$user_id]] <- list() + } resp <- mx.api::mx_keys_query(s, device_keys = query) mx_crypto_report_failures(resp$failures, "/keys/query", strict) - mx_crypto_verify_device_map(resp$device_keys, resp$master_keys, + requested <- resp$device_keys[names(resp$device_keys) %in% user_ids] + mx_crypto_verify_device_map(requested, resp$master_keys, resp$self_signing_keys, self_id = client$user_id, - self_master_key = self_master_key) + self_master_key = self_master_key, + user_signing_keys = resp$user_signing_keys) } # A server we could not reach is not a user with no devices. @@ -135,9 +146,12 @@ mx_crypto_report_failures <- function(failures, what, strict = FALSE) { mx_crypto_verify_device_map <- function(device_keys_map, master_keys = NULL, self_signing_keys = NULL, self_id = NULL, - self_master_key = NULL) { + self_master_key = NULL, + user_signing_keys = NULL) { out <- list() seen <- list() + trusted_masters <- mx_crypto_trusted_master_keys( + master_keys, user_signing_keys, self_id, self_master_key) cross_signed <- mx_crypto_cross_signed_devices( device_keys_map, master_keys %||% list(), self_signing_keys %||% list()) @@ -166,7 +180,10 @@ mx_crypto_verify_device_map <- function(device_keys_map, master_keys = NULL, user_id = uid, device_id = dev, curve25519 = keys$curve25519, ed25519 = keys$ed25519, cross_signed = trusted_chain, - master_key = chain_master %||% NA_character_) + master_key = chain_master %||% NA_character_, + identity_verified = !is.null(chain_master) && + if (identical(uid, self_id)) trusted_chain else + identical(chain_master, trusted_masters[[uid]])) } } attr(out, "seen") <- seen diff --git a/R/user-verification.R b/R/user-verification.R new file mode 100644 index 0000000..f43d642 --- /dev/null +++ b/R/user-verification.R @@ -0,0 +1,138 @@ +# User-to-user trust is a signature over an independently authenticated +# master key. A valid self-signing chain alone is not that trust decision. + +mx_crypto_user_verification_context <- function(client, store_dir, user_id, + master_key) { + mx_require_crypto() + scalar <- function(x) is.character(x) && length(x) == 1L && + !is.na(x) && nzchar(x) + if (!scalar(client$user_id) || !scalar(user_id) || + !startsWith(user_id, "@") || identical(user_id, client$user_id)) { + stop("mx.client: verification requires a different Matrix user id", + call. = FALSE) + } + if (!scalar(master_key) || + !grepl("^[A-Za-z0-9+/]{43}$", master_key)) { + stop("mx.client: supply the full independently verified master key", + call. = FALSE) + } + if (!scalar(store_dir) || + !file.exists(file.path(store_dir, "cross-signing.json")) || + !file.exists(file.path(store_dir, "pickle.key")) || + file.info(file.path(store_dir, "pickle.key"))$size != 32L) { + stop("mx.client: an existing cross-signing store and pickle key are ", + "required; no identity was created", call. = FALSE) + } + keys <- mx_crypto_cross_signing_load(store_dir) + self_master <- mx.crypto::mxc_signing_key_public(keys$master) + self_user <- mx.crypto::mxc_signing_key_public(keys$user_signing) + query <- stats::setNames(list(list(), list()), c(client$user_id, user_id)) + response <- mx.api::mx_keys_query(mx_client_session(client), query) + mx_crypto_report_failures(response$failures, "/keys/query", strict = TRUE) + published_master <- mx_crypto_cross_signing_public( + response$master_keys[[client$user_id]], client$user_id, "master") + if (!identical(published_master, self_master)) { + stop("mx.client: local and homeserver master keys differ; ", + "refusing the changed identity", call. = FALSE) + } + user_key <- response$user_signing_keys[[client$user_id]] + published_user <- mx_crypto_cross_signing_public( + user_key, client$user_id, "user_signing") + if (!identical(published_user, self_user) || + !mx_crypto_signature_valid(user_key, client$user_id, self_master)) { + stop("mx.client: user-signing key does not match the local identity", + call. = FALSE) + } + peer <- response$master_keys[[user_id]] + published_peer <- mx_crypto_cross_signing_public(peer, user_id, "master") + if (!identical(published_peer, master_key)) { + stop("mx.client: peer master key differs from the independently ", + "verified key; refusing the changed identity", call. = FALSE) + } + list(keys = keys, peer = peer, status = list( + user_id = user_id, master_key = master_key, + signer_user_id = client$user_id, signer_master_key = self_master, + verified = mx_crypto_signature_valid(peer, client$user_id, self_user))) +} + +#' Check a directional Matrix identity verification +#' +#' Reads the homeserver's signature on another user's master key and verifies +#' it through this user's locally pinned master and user-signing keys. Both +#' identities must match their expected keys. This does not upload signatures, +#' initialize a store, run sync, or modify encryption sessions. +#' +#' @param client Matrix client config for the account whose trust is checked. +#' @param store_dir Character. That account's existing cross-signing store. +#' @param user_id Character. The other user's full Matrix id. +#' @param master_key Character. The other user's full, unpadded base64 master +#' public key, authenticated through a trusted independent channel. Do not +#' obtain this pin solely from the homeserver response being checked. +#' @return A list with user_id, master_key, signer_user_id, signer_master_key, +#' and verified. FALSE means the expected identities were found but the +#' directional trust signature was absent or invalid. Key mismatches error. +#' @examples +#' \dontrun{ +#' mx_crypto_user_trust(client, existing_store, "@peer:example.org", peer_pin) +#' } +#' @export +mx_crypto_user_trust <- function(client, store_dir, user_id, master_key) { + mx_crypto_user_verification_context( + client, store_dir, user_id, master_key)$status +} + +#' Verify another Matrix user's independently authenticated identity +#' +#' Signs the other user's pinned master key with this account's existing +#' user-signing key. Uploads only a missing or invalid signature, then queries +#' it back and verifies it before reporting success. No private keys are sent. +#' A successful upload followed by a failed read-back may already have recorded +#' trust; rerunning with the same authenticated key is safe. +#' +#' This establishes one direction only. For mutual verification, preflight +#' both accounts with \code{mx_crypto_user_trust()}, then call this function +#' once as each account using the other account's independently checked pin. +#' The two uploads are not atomic. No SAS handshake or history-key sharing is +#' performed, and devices still need their own valid self-signing chains. +#' +#' @param client Matrix client config for the account granting trust. +#' @param store_dir Character. That account's existing cross-signing store. +#' @param user_id Character. The other user's full Matrix id. +#' @param master_key Character. The other user's full master public key, +#' authenticated independently of the homeserver being queried. +#' @return The same status list as \code{mx_crypto_user_trust()}, with verified +#' TRUE, invisibly. An error is raised if read-back does not confirm trust. +#' @examples +#' \dontrun{ +#' mx_crypto_verify_user(client, existing_store, "@peer:example.org", peer_pin) +#' } +#' @export +mx_crypto_verify_user <- function(client, store_dir, user_id, master_key) { + context <- mx_crypto_user_verification_context( + client, store_dir, user_id, master_key) + if (isTRUE(context$status$verified)) { + return(invisible(context$status)) + } + peer <- context$peer + peer$signatures <- NULL + peer$unsigned <- NULL + key_id <- paste0("ed25519:", + mx.crypto::mxc_signing_key_public(context$keys$user_signing)) + signed <- mx_crypto_add_signature( + peer, context$keys$user_signing, client$user_id, key_id) + signatures <- stats::setNames( + list(stats::setNames(list(signed), master_key)), user_id) + response <- mx.api::mx_keys_signatures_upload( + mx_client_session(client), signatures) + if (length(response$failures)) { + stop("mx.client: homeserver rejected the user verification signature", + call. = FALSE) + } + status <- mx_crypto_user_trust(client, store_dir, user_id, master_key) + if (!isTRUE(status$verified)) { + stop("mx.client: uploaded trust signature was not confirmed by ", + "read-back; rerun the check before claiming verification", + call. = FALSE) + } + invisible(status) +} diff --git a/R/verification-transport.R b/R/verification-transport.R new file mode 100644 index 0000000..399bd54 --- /dev/null +++ b/R/verification-transport.R @@ -0,0 +1,142 @@ +# A standalone console is a Matrix client, not a second diagnostic sync loop. +# Only one process may own this account, its cursor, and its crypto store. +verification_context <- function(client, store_dir) { + required <- file.path(store_dir, c("pickle.key", "account.pickle", + "cross-signing.json", "sessions.json")) + if (!all(file.exists(required)) || file.info(required[1])$size != 32L) { + stop("mx.client: an existing initialized crypto store is required", + call. = FALSE) + } + if (is.null(attr(client, "path"))) { + stop("mx.client: load the client from its existing config path first", + call. = FALSE) + } + # Polling saves a new config object; the caller's original list remains + # stale. Resume from disk on every attempt, including after interruption. + saved <- mx_client_load(path = attr(client, "path"), + app = attr(client, "app") %||% "mx.client") + identity_fields <- c("server", "user_id", "device_id") + if (!all(vapply(identity_fields, function(field) { + identical(saved[[field]], client[[field]]) + }, logical(1)))) { + stop("mx.client: saved Matrix identity changed; reload the client ", + "config before verification", call. = FALSE) + } + client <- saved + ctx <- new.env(parent = emptyenv()) + ctx$client <- client + ctx$store_dir <- store_dir + # Verify the token and store describe this exact device before consuming sync. + identity <- mx.api::mx_whoami(mx_client_session(client)) + if (!identical(identity$user_id, client$user_id) || + !identical(identity$device_id, client$device_id)) { + stop("mx.client: access token belongs to a different Matrix device", + call. = FALSE) + } + sas_identity(client, store_dir, client$user_id, client$device_id) + ctx$account <- mx_crypto_account(store_dir) + ctx$sessions <- mx_crypto_sessions_load(store_dir) + ctx$master <- mx.crypto::mxc_signing_key_public( + mx_crypto_cross_signing_load(store_dir)$master) + ctx$pending <- list() + ctx$messages <- list() + ctx +} + +verification_poll <- function(ctx, peer_user_id, on_messages) { + response <- mx_sync_update(ctx$client, timeout = 1000L, save = FALSE) + devices <- tryCatch(mx_crypto_known_devices(ctx$client, + unique(c(ctx$client$user_id, peer_user_id)), self_master_key = ctx$master), + error = function(e) { + warning("mx.client: device lookup failed: ", conditionMessage(e), + call. = FALSE) + NULL + }) + keys <- mx.crypto::mxc_account_identity_keys(ctx$account) + res <- mx_crypto_process_sync(ctx$account, ctx$sessions, response$sync, + keys$curve25519, ctx$client$user_id, devices, ctx$client$device_id) + ctx$sessions <- res$sessions + # No transport or operator callback before ratchets and cursor are saved. + mx_crypto_account_save(ctx$account, ctx$store_dir) + mx_crypto_sessions_save(ctx$sessions, ctx$store_dir) + ctx$client <- mx_client_save(response$client) + normal <- c(mx_extract_text_events(response$sync, ctx$client$user_id), res$events) + ctx$messages <- c(ctx$messages, normal) + if (length(normal)) on_messages(normal) + # Failed key requests remain pending for a future poll with the same id. + if (length(res$key_requests)) tryCatch({ + mx_crypto_send_key_requests(ctx$client, res$key_requests) + ctx$sessions <- mx_crypto_mark_key_requests_sent(ctx$sessions, res$key_requests) + mx_crypto_sessions_save(ctx$sessions, ctx$store_dir) + }, error = function(e) warning("mx.client: room-key request deferred: ", + conditionMessage(e), call. = FALSE)) + if (length(res$key_request_cancellations)) tryCatch( + mx_crypto_send_key_requests(ctx$client, res$key_request_cancellations), + error = function(e) warning("mx.client: room-key cancellation failed: ", + conditionMessage(e), call. = FALSE)) + res$verification_events +} + +verification_recipients <- function(ctx, peer) { + devices <- mx_crypto_known_devices(ctx$client, + unique(c(ctx$client$user_id, peer)), strict = TRUE, + self_master_key = ctx$master) + devices <- Filter(function(d) !(identical(d$user_id, ctx$client$user_id) && + identical(d$device_id, ctx$client$device_id)), devices) + need <- vapply(devices, function(d) is.null(ctx$sessions$olm[[d$curve25519]]), + logical(1)) + recipients <- c(devices[!need], mx_crypto_claim_otks(ctx$client, + devices[need], strict = TRUE)) + recipients <- Filter(function(d) !is.null(d$otk) || + !is.null(ctx$sessions$olm[[d$curve25519]]), recipients) + if (!any(vapply(recipients, function(d) identical(d$user_id, peer), logical(1)))) { + stop("mx.client: no usable peer device for encrypted verification", + call. = FALSE) + } + recipients +} + +verification_send <- function(ctx, envelope, encrypted) { + s <- mx_client_session(ctx$client) + id <- envelope$id + if (is.null(envelope$room_id)) { + mx.api::mx_send_to_device(s, envelope$type, stats::setNames( + list(stats::setNames(list(envelope$content), envelope$device_id)), + envelope$user_id), txn_id = id) + } else if (!encrypted) { + mx.api::mx_send_event(s, envelope$room_id, envelope$type, + envelope$content, txn_id = id) + } else { + pending <- ctx$pending[[id]] + if (is.null(pending)) { + recipients <- verification_recipients(ctx, envelope$user_id) + old_shared <- ctx$sessions$megolm_out[[envelope$room_id]]$shared + keys <- mx.crypto::mxc_account_identity_keys(ctx$account) + pending <- mx_crypto_encrypt_for_devices(ctx$account, ctx$sessions, + envelope$room_id, envelope$content, keys$curve25519, + ctx$client$device_id, recipients, ctx$client$user_id, + event_type = envelope$type) + ctx$sessions <- pending$sessions + # Do not persist delivery markers for room keys not yet sent. + ctx$sessions$megolm_out[[envelope$room_id]]$shared <- + old_shared %||% character() + ctx$pending[[id]] <- pending + } + # Saving is retried before sending after an earlier save failure too. + mx_crypto_account_save(ctx$account, ctx$store_dir) + mx_crypto_sessions_save(ctx$sessions, ctx$store_dir) + for (i in seq_along(pending$to_device)) { + item <- pending$to_device[[i]] + mx.api::mx_send_to_device(s, "m.room.encrypted", stats::setNames( + list(stats::setNames(list(item$content), item$device_id)), item$user_id), + txn_id = paste0(id, "-key-", i)) + } + mx.api::mx_send_event(s, envelope$room_id, "m.room.encrypted", + pending$event, txn_id = id) + ctx$sessions$megolm_out[[envelope$room_id]]$shared <- + pending$sessions$megolm_out[[envelope$room_id]]$shared + mx_crypto_sessions_save(ctx$sessions, ctx$store_dir) + ctx$pending[[id]] <- NULL + } + invisible(NULL) +} diff --git a/R/verify-console.R b/R/verify-console.R new file mode 100644 index 0000000..bbad4d8 --- /dev/null +++ b/R/verify-console.R @@ -0,0 +1,89 @@ +verification_print_messages <- function(events) { + # Do not interleave untrusted chat text or terminal escapes with a trusted + # verification prompt. The caller can review content in result$messages. + message("Received ", length(events), + " ordinary message(s); retained in result$messages for review.") +} + +#' Verify a Matrix client interactively from an R console +#' +#' Owns the device's existing sync cursor and crypto store while waiting for +#' a verification request from the selected user and room. In the other +#' client, choose Start verification, then compare the emoji or numbers in +#' its UI with this trusted console. No private client database, desktop +#' keyring, or remote user's private signing key is read. +#' +#' Stop any bot or other process using this device before calling. The +#' exclusive argument is an explicit operator assertion, not a process lock. +#' Restart that process only after this call returns. Ordinary messages read +#' during this session go to on_messages and are returned for review; a bot +#' will not automatically process messages whose cursor the console advanced. +#' Existing event-loop hosts can instead use mx_sas_from_request() and +#' mx_sas_console() with their own receive/send callbacks. +#' Each attempt reloads the saved cursor and credentials, refusing a changed +#' server, user, or device identity rather than replaying the caller's old cursor. +#' +#' @param client Client loaded with mx_client_load() from its existing config. +#' @param store_dir This device's existing, initialized crypto store. +#' @param peer_user_id Full Matrix id of the person being verified. +#' @param room_id Exact room id, or NULL for to-device requests. +#' @param exclusive Must explicitly be TRUE after stopping other consumers +#' of this device and store. FALSE refuses before any network or store access. +#' @param timeout Maximum seconds to wait for an initial request, 1 to 600. +#' @param on_messages Function receiving ordinary messages read while the +#' console owns sync. The default reports only their count, keeping untrusted +#' chat text out of the verification prompts; content is returned in messages. +#' @return Invisibly, a list with status, updated client, and ordinary messages. +#' @examples +#' \dontrun{ +#' # First stop the service using this exact device and crypto store. +#' client <- mx_client_load(path = config_path) +#' result <- mx_verify_console(client, existing_store, +#' "@peer:example.org", "!room:example.org", exclusive = TRUE) +#' # In the peer's Matrix app, choose Start verification. +#' # Restart the service after the console returns. +#' } +#' @export +mx_verify_console <- function(client, store_dir, peer_user_id, room_id = NULL, + exclusive = FALSE, timeout = 120, on_messages = verification_print_messages) { + if (!identical(exclusive, TRUE)) { + stop("mx.client: stop other consumers of this device, then explicitly ", + "set exclusive = TRUE; no sync or store access was started", call. = FALSE) + } + if (!interactive()) { + stop("mx.client: start verification in an interactive R console", call. = FALSE) + } + if (!sas_scalar(peer_user_id) || !startsWith(peer_user_id, "@") || + (!is.null(room_id) && (!sas_scalar(room_id) || !startsWith(room_id, "!"))) || + !is.numeric(timeout) || length(timeout) != 1L || !is.finite(timeout) || + timeout < 1 || timeout > 600 || !is.function(on_messages)) { + stop("mx.client: invalid verification peer, room, timeout, or callback", call. = FALSE) + } + sas_require_crypto() + ctx <- verification_context(client, store_dir) + + encrypted <- !is.null(room_id) && mx_room_encrypted(ctx$client, room_id) + receive <- function() verification_poll(ctx, peer_user_id, on_messages) + send <- function(event) verification_send(ctx, event, encrypted) + complete <- function(sas) mx_sas_record_trust(sas, ctx$client, store_dir) + message("Waiting for Start verification from ", peer_user_id, + if (is.null(room_id)) " (to-device)" else paste0(" in ", room_id), ".") + deadline <- Sys.time() + timeout + sas <- NULL + repeat { + events <- receive() + for (event in events) { + if (!identical(event$sender, peer_user_id) || + !identical(event$room_id, room_id)) next + if (is.null(sas)) { + sas <- mx_sas_from_request(ctx$client, store_dir, event) + } else mx_sas_receive(sas, event) + } + if (!is.null(sas) || Sys.time() >= deadline) break + } + status <- if (is.null(sas)) { + message("No current verification request received before the timeout.") + list(phase = "timeout", local_trust_recorded = FALSE) + } else mx_sas_console(sas, receive, send, complete) + invisible(list(status = status, client = ctx$client, messages = ctx$messages)) +} diff --git a/README.md b/README.md index f98b35e..fff1f6c 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,75 @@ cross-signing bootstrap verifies the master -> self-signing -> device chain; and missing-session requests accept forwarded keys only from this user's cross-signed devices. See `vignette("e2ee", package = "mx.client")`. +### Interactive verification from R + +With mx.client >= 0.2.0.10 and mx.crypto >= 0.2.1.2, use the standard +Matrix SAS handshake to compare 7 emoji or 3 numbers with FluffyChat or +another compatible client. Each client keeps its own signing keys. + +```r +# Stop any bot using this exact device before the console takes over sync. +client <- mx_client_load(path = config_path) +result <- mx_verify_console(client, existing_crypto_store, + "@peer:example.org", "!room:example.org", exclusive = TRUE) +# On the peer's app, choose Start verification, then compare the displays. +# Restart the bot only after the console returns. +``` + +The R console can run over SSH on the bot's host while FluffyChat runs on +another computer. Both clients need their existing cross-signing identities; +FluffyChat may ask its owner to unlock its own signing keys. No FluffyChat +database access or transfer of private keys is needed. Confirmation is a +human action on both clients. Never send comparison values through the room +being verified or let an LLM confirm them. + +The console consumes incoming messages while it owns sync and returns them +in `result$messages`; a paused bot will not automatically process those +messages. See the [E2EE vignette](vignettes/e2ee.md#interactive-sas-verification) +for existing-event-loop integration, failure handling, and completion checks. + +If FluffyChat shows **Restore Crypto Identity** or the console reports +`peer_master_missing`, follow the [peer identity recovery guide](vignettes/e2ee.md#peer-identity-recovery-in-fluffychat). +It distinguishes device trust from account trust, identifies the right account +in a multi-account app, and separates recovery from an explicitly authorized +identity reset. Neither verification nor encrypted messaging requires access +to the other client's database. + +### Procedural identity verification + +With mx.client 0.2.0.10, `mx_crypto_verify_user()` signs another user's +independently authenticated master key and confirms the signature by +read-back. `mx_crypto_user_trust()` checks that directional trust without +writing. Run each as both accounts for mutual verification, using their +existing private signing keys. The [E2EE vignette](vignettes/e2ee.md#mutual-verification-from-an-r-console) +contains the console procedure and its key-pinning requirements. + +### Recovering a missed room key + +A signed device and a readable encrypted message are separate checks. +Cross-signing publishes a verifiable device identity; decryption also needs +the sender's Megolm room key. SAS verification does not recover missing history. + +Since mx.client 0.2.0.9, incoming Olm messages are tried against both remotely +and locally initiated sessions. A peer can reply with a room key over the +session this client opened. Failed or replayed prekeys no longer abort the +whole receive batch. The receiving process must load the fixed version; +replacing package files does not update an already loaded R namespace. + +If the sender keeps using a session whose key was missed before the fix, +rotate that sender's outgoing room session. In FluffyChat, send +`/discardsession` by itself in the affected room, then send a new test message +from the same client. This is a room-scoped command handled by FluffyChat's +[Matrix SDK](https://github.com/famedly/matrix-dart-sdk/blob/main/doc/commands.md). +A leave/rejoin or another message alone does not guarantee a new session. +Rotation preserves existing history but does not recover keys already missed. + +Confirm that the receiver installs the new inbound Megolm session, reads the +test message, and replies with an `m.room.encrypted` event. A lock icon alone +does not establish a working round trip. Keep the existing device and crypto +store. See the [Matrix messaging skill's recovery procedure](inst/skills/matrix-messaging/SKILL.md#recovering-a-missed-room-key) +for safe inspection and recovery boundaries. + ## The package family | Package | Role | Depends on | diff --git a/inst/skills/matrix-messaging/SKILL.md b/inst/skills/matrix-messaging/SKILL.md index f8ad261..d23fa87 100644 --- a/inst/skills/matrix-messaging/SKILL.md +++ b/inst/skills/matrix-messaging/SKILL.md @@ -4,7 +4,8 @@ description: > Send and receive Matrix messages from R using the mx.* package family (mx.api / mx.crypto / mx.client). Use when a user wants an R program or agent to post to a Matrix room, read new messages, accept invites, send - files or tables, or talk to a Matrix homeserver. Posts go through + files or tables, troubleshoot missed E2EE room keys, or talk to a Matrix + homeserver. Posts go through mx.client (config, room resolution, HTML formatting) over mx.api, never hand-rolled curl. End-to-end encryption is orchestrated over the optional mx.crypto package. @@ -75,13 +76,12 @@ matrix.to pill. A pill implies an HTML body even without `markdown = TRUE`. ## Resolve rooms Send by human name instead of `!opaque:id`. `mx_resolve_room()` turns a name -into an id (or passes a literal `!id`/`#alias` through); `mx_room_lookup_by_name()` -lists the joined rooms, which is how you find a DM (the room whose members -are just the bot and one person). +into an id (or passes a literal `!id` through). `mx_room_lookup_by_name()` +finds a joined room with the supplied display name, returning its id or NULL. ```r room_id <- mx.client::mx_resolve_room(client, "general") -mx.client::mx_room_lookup_by_name(client) # name -> id table +mx.client::mx_room_lookup_by_name(client, "general") ``` ## Send files and media @@ -137,13 +137,122 @@ the E2EE entry points, so plaintext clients install and run without a Rust toolchain. Cross-signing bootstrap is fail-closed, and missing Megolm sessions produce durable key requests that accept forwarded keys only from the same user's cross-signed devices. Check a room's state with `mx_room_encrypted()` -before choosing the encrypted or plaintext path. This does not include SAS -verification or cross-user history recovery. +before choosing the encrypted or plaintext path. SAS verification requires +mx.client >= 0.2.0.10 and mx.crypto >= 0.2.1.2; cross-user history recovery is absent. The full flow (store, account, key publish, `mx_send_encrypted()`, `mx_crypto_process_sync()`) and its current limitations are in `vignette("e2ee", package = "mx.client")`. +### Interactive verification + +Use the vignette's interactive SAS procedure and `mx_verify_console()` with +the exact existing config and crypto store. The console may run over SSH +while the peer uses FluffyChat on another computer. Do not extract another +client's database or private keys. The human compares and confirms both +displays; never pass SAS values through Matrix or have an LLM affirm them. + +A standalone console requires approved exclusive ownership: stop the bot +using that device first and restart it after the console returns. It consumes +ordinary messages too, returning them for review rather than queueing them +for the paused bot. Existing hosts can use `verification_events` and SAS +callbacks in their sole event loop. Distinguish local trust read-back from +the peer's done acknowledgement and from live encrypted-message delivery. + +For FluffyChat recovery, use the vignette's **Peer identity recovery in +FluffyChat** section. Confirm the full Matrix ID before opening recovery in +a multi-account app, and keep that account selected through authentication. +Use the existing encrypted DM; a 2-person room is not necessarily a DM. +A signed device, trusted account master, and readable encrypted conversation +are separate checks. Old device activity timestamps are not sufficient +evidence that a device is unavailable. + +`peer_master_missing` is a failed account-identity proof even when emoji +match. Restore the peer's identity through its normal app first. Device-only +verification, including skipping a recovery prompt, cannot recreate absent +signing keys. **Restore Crypto Identity** can also mean a missing backup +secret, not loss of all signing keys. Never weaken the master-MAC check. +If recovery is unavailable, explain the history/trust consequences and get +explicit owner approval before a crypto-identity reset. Preserve the login, +device, and local room keys; do not use **Export session and wipe device**. +The password authorizing new identity keys is the Matrix account password, +not the computer password or backup passphrase. Keep all secrets local. +Reset the selected account only once; other sessions should restore the new +identity, not reset again. Encrypted messages appearing on another session +without a fresh login do not prove signing-key recovery. Retry SAS with a +fresh request, inspect `phase`, `local_trust_recorded`, and `peer_done`, then +test a new message after the console exits and the bot resumes. + +### Procedural mutual verification + +mx.client >= 0.2.0.10 provides `mx_crypto_user_trust(client, store_dir, +user_id, master_key)` for a read-only directional trust check and +`mx_crypto_verify_user()` with the same arguments to upload and confirm a +trust signature. Use the vignette's mutual-verification console procedure. +Preflight both identities before either upload; verify each direction from +its own account. Another user's user-signing key is not publicly queryable. + +Require the full peer master key authenticated through a trusted independent +channel. Copying `/keys/query` into the expected-key argument is not identity +verification. Preserve existing identities: if a client's private signing +key is unavailable, ask its owner to unlock it through that client. Do not +reset cross-signing, extract recovery secrets into chat, or claim mutual +verification from one successful signature. The uploads are not atomic; +report partial success and recheck before retrying. + +The new `identity_verified` device metadata requires the trust signature and +device chain. It does not change recipient policy or the older +`sender_verified` field, which attests device binding rather than user trust. + +### Recovering a missed room key + +Keep three checks separate: cross-signing validates a device's signature +chain against a trusted master; decryption requires the sender's Megolm room +key; interactive SAS verification authenticates keys. A signed-device badge +or an encrypted-room icon does not prove the receiver can read a message. +Use the vignette's bootstrap procedure when cross-signing is actually needed; +preserve the existing account and use its local master pin for chain checks. + +For a receiver that sends encrypted messages but cannot read replies: + +1. Verify the package path/version loaded by the receiving process. mx.client + 0.2.0.9 fixes receipt over a locally initiated Olm session: both stored + directions are tried for normal and prekey messages before guarded inbound + creation. With deployment approval, stop affected consumers before replacing + a shared R package, then restart them. An installed version check alone + does not prove an older process loaded it. +2. Inspect the actual room and sender device. Read recent room-history metadata + with `mx.api::mx_messages(mx.client::mx_client_session(client), room_id)` + and compare the event's `session_id` with persisted `megolm_in` and + `key_requests`. Read metadata from the exact store the application uses; + do not guess a store path or construct a new account for inspection. + An unchanged sync cursor can mean no new events. Also, `olm_in` can stay + empty while replies decrypt through sessions in `olm`; check the relevant + inbound Megolm session and plaintext, not one map's count. +3. If new messages reuse a session whose key was missed, ask the sender to + rotate that room's outgoing session. In FluffyChat, the sender enters + `/discardsession` as a standalone command in the affected room, then sends + a new short message from that same client. This command is interpreted by + the sender's app, not by the bot or an R console. It resets that room's + outgoing group session; it does not reset the account or delete history. + See the [Matrix Dart SDK command documentation](https://github.com/famedly/matrix-dart-sdk/blob/main/doc/commands.md). + Do not assume a leave/rejoin or another message rotated the session: + compare session IDs. Rotation supplies a new session for future messages; + it does not recover a missing historical key. +4. Verify a new session ID, its matching persisted inbound Megolm key, the + expected decrypted test text, and (for a replying bot) an encrypted reply + visible to the sender. If one rotation attempt still leaves the key missing, + inspect key delivery and sender policy before asking for further resets. + +Do not run a second `/sync` consumer or save crypto state from a diagnostic +console while the bot owns that device. Room-history queries do not consume +the to-device queue. Do not rewind sync, delete the crypto store, or weaken +forwarded-key admission to make the test pass. Key requests in this stack go +to the bot's own devices and accept forwarded keys only from its cross-signed +devices; with no such device holding the old key, that request cannot recover +the old session. Keep deployment identities, room IDs, and incident logs out +of public package documentation. + ## Report End with: which identity (app/config) posted, which room, the message, and diff --git a/inst/tinytest/test_sas.R b/inst/tinytest/test_sas.R new file mode 100644 index 0000000..e8e165d --- /dev/null +++ b/inst/tinytest/test_sas.R @@ -0,0 +1,212 @@ +library(tinytest) +if (!requireNamespace("mx.crypto", quietly = TRUE) || + !"mxc_sas_commitment" %in% getNamespaceExports("mx.crypto")) { + exit_file("mx.crypto SAS primitives are unavailable") +} +library(mx.client) + +local({ + make_keys <- function(device) { + k <- replicate(2, mx.crypto::mxc_signing_key_public( + mx.crypto::mxc_signing_key_new())) + setNames(as.list(k), paste0("ed25519:", c(device, k[2]))) + } + ak <- make_keys("A") + bk <- make_keys("B") + pair <- function(room = NULL, same_user = FALSE) { + alice <- "@alice:example.org" + bob <- if (same_user) alice else "@bob:example.org" + list(a = mx_sas_session(alice, "A", ak, bob, "B", bk, + "transaction", room, initiator = TRUE, now = 100), + b = mx_sas_session(bob, "B", bk, alice, "A", ak, + "transaction", room, now = 100)) + } + transfer <- function(from, to, change = identity) { + pending <- mx_sas_outgoing(from) + for (item in pending) { + event <- change(list(type = item$type, content = item$content, + sender = from$user_id, room_id = item$room_id)) + mx_sas_receive(to, event, now = 101) + mx_sas_outgoing(from, item$id) + } + invisible(length(pending)) + } + exchange <- function(p) { + mx_sas_accept(p$b, now = 100) + for (i in 1:3) { + transfer(p$b, p$a) + transfer(p$a, p$b) + } + p + } + + # Real two-party crypto: both transports, either user ordering, and same user. + for (room in list(NULL, "!room:example.org")) { + for (same in c(FALSE, TRUE)) { + p <- exchange(pair(room, same)) + a <- mx_sas_status(p$a) + b <- mx_sas_status(p$b) + expect_identical(a$phase, "sas") + expect_identical(b$phase, "sas") + expect_identical(a$decimal, b$decimal) + expect_identical(a$emoji, b$emoji) + expect_equal(length(a$decimal), 3L) + expect_equal(length(a$emoji), 7L) + expect_false(a$local_trust_recorded) + mx_sas_confirm(p$b, TRUE, now = 101) + transfer(p$b, p$a) + expect_true(mx_sas_status(p$a)$peer_mac_valid) + expect_identical(mx_sas_status(p$a)$phase, "sas") + # Receiving an authenticated MAC never substitutes for human comparison. + expect_error(mx_sas_record_trust(p$a, + list(user_id = p$a$user_id, device_id = "A"), tempfile(), now = 101), + "human confirmation") + mx_sas_confirm(p$a, TRUE, now = 101) + transfer(p$a, p$b) + expect_identical(mx_sas_status(p$a)$phase, "verified") + expect_identical(mx_sas_status(p$b)$phase, "verified") + expect_false(mx_sas_status(p$a)$local_trust_recorded) + } + } + + # The Matrix formulas at the two extrema, independent of key agreement. + low <- mx.client:::sas_display(as.raw(rep(0, 6))) + high <- mx.client:::sas_display(as.raw(rep(255, 6))) + expect_equal(low$decimal, rep(1000, 3)) + expect_equal(high$decimal, rep(9191, 3)) + expect_identical(low$descriptions, rep("Dog", 7)) + expect_identical(high$descriptions, rep("Pin", 7)) + expect_equal(length(mx.client:::sas_emoji()$emoji), 64L) + + p <- exchange(pair()) + mx_sas_confirm(p$a, FALSE, now = 101) + expect_identical(mx_sas_status(p$a)$cancel_code, "m.mismatched_sas") + transfer(p$a, p$b) + expect_identical(mx_sas_status(p$b)$phase, "cancelled") + expect_equal(length(mx_sas_outgoing(p$b)), 0L) + expect_error(mx_sas_confirm(exchange(pair())$a, NA, now = 101), "explicitly") + expect_error(mx_sas_receive(pair()$a, now = NA), "finite") + expect_error(mx_sas_receive(pair()$a, now = numeric()), "finite") + + # Timeouts: request prompt is two minutes, active exchange is ten minutes. + p <- pair() + mx_sas_receive(p$b, now = 220) + expect_identical(mx_sas_status(p$b)$cancel_code, "m.timeout") + p <- exchange(pair()) + mx_sas_receive(p$a, now = 250) + expect_identical(mx_sas_status(p$a)$phase, "sas") + mx_sas_receive(p$a, now = 700) + expect_identical(mx_sas_status(p$a)$cancel_code, "m.timeout") + expect_false(mx_sas_status(p$a)$local_trust_recorded) + + # Transport retry retains the exact pending payload and id. + p <- pair() + mx_sas_accept(p$b, now = 100) + pending <- mx_sas_outgoing(p$b) + expect_identical(mx_sas_outgoing(p$b), pending) + other <- pair() + mx_sas_accept(other$b, now = 100) + # Equal peer-supplied transaction ids must not collide in our HTTP scope. + expect_identical(other$b$transaction_id, p$b$transaction_id) + expect_false(identical(mx_sas_outgoing(other$b)[[1]]$id, pending[[1]]$id)) + expect_error(mx_sas_outgoing(p$b, "wrong-id"), "unknown") + transfer(p$b, p$a) + start <- mx_sas_outgoing(p$a)[[1]] + ev <- list(type = start$type, content = start$content, sender = p$a$user_id) + mx_sas_receive(p$b, ev, now = 101) + accepted <- mx_sas_outgoing(p$b) + mx_sas_receive(p$b, ev, now = 101) + expect_identical(mx_sas_outgoing(p$b), accepted) + ev$content$hashes <- list("sha512") + mx_sas_receive(p$b, ev, now = 101) + expect_identical(mx_sas_status(p$b)$cancel_code, "m.unexpected_message") + + # Wrong sender/device/transaction/room never advances the transaction. + for (field in c("sender", "device", "transaction", "room", "relation")) { + p <- pair("!room:example.org") + mx_sas_accept(p$b, now = 100) + transfer(p$b, p$a, function(e) { + if (field == "sender") e$sender <- "@mallory:example.org" + if (field == "device") e$content$from_device <- "OTHER" + if (field == "transaction") e$content$`m.relates_to`$event_id <- "other" + if (field == "room") e$room_id <- "!other:example.org" + if (field == "relation") e$content$`m.relates_to` <- "malformed" + e + }) + expect_identical(mx_sas_status(p$a)$phase, "requested") + } + + # Commitment binds the full start including transport relation fields. + p <- pair("!room:example.org") + mx_sas_accept(p$b, now = 100) + transfer(p$b, p$a) + transfer(p$a, p$b, function(e) {e$content$extra <- "tampered"; e}) + transfer(p$b, p$a) + transfer(p$a, p$b) + transfer(p$b, p$a) + expect_identical(mx_sas_status(p$a)$cancel_code, "m.mismatched_commitment") + + # Corrupt a valid peer MAC, remove the master, or alter the key-list MAC. + for (fault in c("mac", "master", "keys")) { + p <- exchange(pair()) + mx_sas_confirm(p$b, TRUE, now = 101) + transfer(p$b, p$a, function(e) { + if (fault == "mac") e$content$mac[[1]] <- "***" + if (fault == "master") e$content$mac <- e$content$mac["ed25519:B"] + if (fault == "keys") e$content$keys <- "***" + e + }) + expect_identical(mx_sas_status(p$a)$cancel_code, "m.key_mismatch") + expect_false(mx_sas_status(p$a)$peer_mac_valid) + } + + # A real device-only proof differs from dropping a key out of a two-key + # MAC. FluffyChat sends this when its own master is not locally verified. + for (corrupt in c(FALSE, TRUE)) { + p <- exchange(pair()) + info <- mx.client:::sas_info(p$b, mac = TRUE) + id <- "ed25519:B" + content <- list(transaction_id = "transaction", mac = setNames(list( + mx.crypto::mxc_sas_mac(p$b$crypto, bk[[id]], paste0(info, id))), id), + keys = mx.crypto::mxc_sas_mac(p$b$crypto, id, paste0(info, "KEY_IDS"))) + if (corrupt) content$keys <- "***" + mx_sas_receive(p$a, list(type = "m.key.verification.mac", + sender = p$b$user_id, content = content), now = 101) + status <- mx_sas_status(p$a) + expect_identical(status$cancel_code, "m.key_mismatch") + expect_false(status$peer_mac_valid) + expect_false(status$local_trust_recorded) + if (corrupt) expect_null(status$cancel_detail) else { + expect_identical(status$cancel_detail, "peer_master_missing") + expect_message(mx_sas_console(p$a, function() list(), function(e) NULL, + function(sas) stop("must not record trust"), input = function(p) "yes"), + "did not authenticate its master key") + } + } + + # No agreement on modern algorithms must fail before showing a SAS. + p <- pair() + mx_sas_accept(p$b, now = 100) + transfer(p$b, p$a) + transfer(p$a, p$b, function(e) { + e$content$message_authentication_codes <- list("hkdf-hmac-sha256"); e + }) + expect_identical(mx_sas_status(p$b)$cancel_code, "m.unknown_method") + + # Simultaneous starts select the lexically smaller identity's start. + p <- pair() + mx_sas_accept(p$b, now = 100) + transfer(p$b, p$a) + mx_sas_start(p$b, now = 101) + transfer(p$b, p$a) + transfer(p$a, p$b) + for (i in 1:2) {transfer(p$b, p$a); transfer(p$a, p$b)} + expect_identical(mx_sas_status(p$a)$phase, "sas") + expect_identical(mx_sas_status(p$a)$decimal, mx_sas_status(p$b)$decimal) + + # Done before key authentication cannot grant trust or complete the flow. + p <- exchange(pair()) + mx_sas_receive(p$a, list(type = "m.key.verification.done", + sender = p$b$user_id, content = list(transaction_id = "transaction")), now = 101) + expect_identical(mx_sas_status(p$a)$cancel_code, "m.unexpected_message") +}) diff --git a/inst/tinytest/test_sas_identity.R b/inst/tinytest/test_sas_identity.R new file mode 100644 index 0000000..d403cb4 --- /dev/null +++ b/inst/tinytest/test_sas_identity.R @@ -0,0 +1,301 @@ +library(tinytest) +if (!requireNamespace("mx.crypto", quietly = TRUE) || + !"mxc_sas_commitment" %in% getNamespaceExports("mx.crypto")) { + exit_file("mx.crypto SAS primitives are unavailable") +} +library(mx.client) + +local({ + ids <- c("@alice:example.org", "@bob:example.org") + stores <- c(tempfile("sas-alice-"), tempfile("sas-bob-")) + on.exit(unlink(stores, recursive = TRUE), add = TRUE) + signing <- lapply(ids, function(x) mx.client:::mx_crypto_cross_signing_new()) + accounts <- lapply(ids, function(x) mx.crypto::mxc_account_new()) + clients <- lapply(ids, function(id) list(server = "https://example.invalid", + token = "fixture", user_id = id, device_id = "DEVICE")) + objects <- lapply(seq_along(ids), function(i) { + mx.client:::mx_crypto_cross_signing_save(signing[[i]], stores[[i]]) + mx_crypto_account_save(accounts[[i]], stores[[i]]) + mx_crypto_sessions_save(mx_crypto_sessions_new(), stores[[i]]) + mx.client:::mx_crypto_cross_signing_objects(signing[[i]], ids[[i]], + accounts[[i]], "DEVICE") + }) + current <- list( + master_keys = setNames(lapply(objects, `[[`, "master"), ids), + self_signing_keys = setNames(lapply(objects, `[[`, "self_signing"), ids), + user_signing_keys = setNames(lapply(objects, `[[`, "user_signing"), ids), + device_keys = setNames(lapply(seq_along(ids), function(i) { + list(DEVICE = mx_crypto_device_keys(accounts[[i]], ids[[i]], "DEVICE")) + }), ids)) + pristine <- current + queries <- uploads <- 0L + mode <- "ok" + original_query <- mx.api::mx_keys_query + original_upload <- mx.api::mx_keys_signatures_upload + on.exit({ + assignInNamespace("mx_keys_query", original_query, ns = "mx.api") + assignInNamespace("mx_keys_signatures_upload", original_upload, ns = "mx.api") + }, add = TRUE) + assignInNamespace("mx_keys_query", function(session, ...) { + queries <<- queries + 1L + response <- current + response$user_signing_keys <- response$user_signing_keys[session$user_id] + response + }, ns = "mx.api") + assignInNamespace("mx_keys_signatures_upload", function(session, signatures) { + uploads <<- uploads + 1L + if (mode == "reject") return(list(failures = list(peer = "rejected"))) + if (mode == "drop") return(list()) + for (uid in names(signatures)) for (key in names(signatures[[uid]])) { + current$master_keys[[uid]]$signatures[[session$user_id]] <<- + signatures[[uid]][[key]]$signatures[[session$user_id]] + } + list() + }, ns = "mx.api") + request <- function() list(type = "m.room.message", sender = ids[2], + room_id = "!room:example.org", event_id = "$request", + origin_server_ts = as.numeric(Sys.time()) * 1000, + content = list(msgtype = "m.key.verification.request", to = ids[1], + body = "Verify", methods = list("m.sas.v1"), from_device = "DEVICE")) + from <- function(event = request()) mx_sas_from_request(clients[[1]], stores[1], event) + sas <- from() + expect_inherits(sas, "mx_sas") + expect_identical(mx_sas_status(sas)$phase, "requested") + expect_equal(queries, 2L) + expect_equal(uploads, 0L) + files <- unlist(lapply(stores, list.files, full.names = TRUE)) + before <- tools::md5sum(files) + + for (fault in c("old", "future", "time", "recipient", "method", "own", "device")) { + ev <- request() + if (fault == "old") ev$origin_server_ts <- ev$origin_server_ts - 601000 + if (fault == "future") ev$origin_server_ts <- ev$origin_server_ts + 301000 + if (fault == "time") ev$origin_server_ts <- "not a timestamp" + if (fault == "recipient") ev$content$to <- "@other:example.org" + if (fault == "method") ev$content$methods <- list("m.qr_code.show.v1") + if (fault == "own") ev$sender <- ids[1] + if (fault == "device") ev$content$from_device <- NULL + expect_null(from(ev)) + } + expect_equal(queries, 2L) + ev <- request() + ev$room_id <- NULL + ev$type <- "m.key.verification.request" + ev$content$timestamp <- ev$origin_server_ts + ev$content$transaction_id <- "to-device-request" + expect_inherits(from(ev), "mx_sas") + absent <- tempfile("sas-no-store-") + expect_error(mx_sas_from_request(clients[[1]], absent, request()), "existing crypto store") + expect_false(dir.exists(absent)) + current$master_keys[[ids[1]]] <- objects[[2]]$master + expect_error(from(), "master") + current <- pristine + current$device_keys[[ids[1]]]$DEVICE <- mx_crypto_device_keys( + accounts[[2]], ids[1], "DEVICE") + expect_error(from(), "device keys differ") + current <- pristine + current$user_signing_keys[[ids[1]]]$signatures <- list() + expect_error(from(), "user-signing key") + current <- pristine + expect_identical(tools::md5sum(files), before) + expect_equal(uploads, 0L) + + make_pair <- function() { + a <- from() + b <- mx_sas_session(ids[2], "DEVICE", a$peer_keys, ids[1], "DEVICE", + a$keys, a$transaction_id, a$room_id, initiator = TRUE) + list(a = a, b = b) + } + transfer <- function(from, to) { + for (e in mx_sas_outgoing(from)) { + mx_sas_receive(to, list(sender = from$user_id, room_id = e$room_id, + type = e$type, content = e$content)) + mx_sas_outgoing(from, e$id) + } + } + exchange <- function(p) { + mx_sas_accept(p$a) + for (i in 1:3) {transfer(p$a, p$b); transfer(p$b, p$a)} + mx_sas_confirm(p$a, TRUE) + mx_sas_confirm(p$b, TRUE) + transfer(p$a, p$b) + transfer(p$b, p$a) + p + } + p <- exchange(make_pair()) + expect_identical(mx_sas_status(p$a)$phase, "verified") + mx_sas_record_trust(p$a, clients[[1]], stores[1]) + expect_true(mx_sas_status(p$a)$local_trust_recorded) + expect_false(mx_sas_status(p$a)$peer_done) + expect_equal(uploads, 1L) + mx_sas_record_trust(p$a, clients[[1]], stores[1]) + expect_equal(uploads, 1L) + transfer(p$a, p$b) + mx_sas_record_trust(p$b, clients[[2]], stores[2]) + transfer(p$b, p$a) + expect_identical(mx_sas_status(p$a)$phase, "done") + expect_identical(mx_sas_status(p$b)$phase, "done") + expect_equal(uploads, 2L) + for (i in 1:2) expect_true(mx_crypto_user_trust(clients[[i]], stores[i], ids[3-i], + mx.crypto::mxc_signing_key_public(signing[[3-i]]$master))$verified) + + # Changed keys cancel before any new upload. A fresh valid exchange succeeds above. + current <- pristine + p <- exchange(make_pair()) + current$device_keys[[ids[2]]]$DEVICE <- mx_crypto_device_keys( + accounts[[1]], ids[2], "DEVICE") + expect_error(mx_sas_record_trust(p$a, clients[[1]], stores[1]), "keys changed") + expect_equal(uploads, 2L) + expect_identical(mx_sas_status(p$a)$cancel_code, "m.key_mismatch") + current <- pristine + p <- exchange(make_pair()) + mode <- "reject" + expect_error(mx_sas_record_trust(p$a, clients[[1]], stores[1]), "rejected") + expect_false(mx_sas_status(p$a)$local_trust_recorded) + mode <- "drop" + expect_error(mx_sas_record_trust(p$a, clients[[1]], stores[1]), "not confirmed") + expect_false(mx_sas_status(p$a)$local_trust_recorded) + mode <- "ok" + mx_sas_record_trust(p$a, clients[[1]], stores[1]) + expect_true(mx_sas_status(p$a)$local_trust_recorded) + + # The console drives real protocol and trust code, with synthetic human input. + current <- pristine + p <- make_pair() + prompts <- receives <- sends <- completes <- 0L + input <- function(prompt) {prompts <<- prompts + 1L; "yes"} + send <- function(e) { + sends <<- sends + 1L + mx_sas_receive(p$b, list(type = e$type, content = e$content, + room_id = e$room_id, sender = ids[1])) + } + receive <- function() { + receives <<- receives + 1L + if (identical(p$b$phase, "sas")) mx_sas_confirm(p$b, TRUE) + if (identical(p$b$phase, "verified") && !p$b$local_trust_recorded) { + mx_sas_record_trust(p$b, clients[[2]], stores[2]) + } + out <- mx_sas_outgoing(p$b) + for (e in out) mx_sas_outgoing(p$b, e$id) + lapply(out, function(e) list(type = e$type, content = e$content, + room_id = e$room_id, sender = ids[2])) + } + complete <- function(sas) { + completes <<- completes + 1L + mx_sas_record_trust(sas, clients[[1]], stores[1]) + } + result <- suppressMessages(mx_sas_console(p$a, receive, send, complete, input)) + expect_identical(result$phase, "done") + expect_true(result$local_trust_recorded) + expect_equal(prompts, 2L) + expect_true(receives >= 4L) + expect_true(sends >= 4L) + expect_equal(completes, 1L) + expect_error(mx_verify_console(NULL, NULL, NULL), "exclusive = TRUE") + expect_identical(tools::md5sum(files), before) + + # Standalone ownership preflight checks both token and existing store. + cfg_path <- file.path(stores[1], "client.json") + cfg <- mx_client_save(mx_client_from_config(clients[[1]], path = cfg_path)) + whoami <- mx.api::mx_whoami + sync <- mx.api::mx_sync + save_account <- mx_crypto_account_save + save_sessions <- mx_crypto_sessions_save + save_client <- mx_client_save + on.exit({ + assignInNamespace("mx_whoami", whoami, "mx.api") + assignInNamespace("mx_sync", sync, "mx.api") + assignInNamespace("mx_crypto_account_save", save_account, "mx.client") + assignInNamespace("mx_crypto_sessions_save", save_sessions, "mx.client") + assignInNamespace("mx_client_save", save_client, "mx.client") + }, add = TRUE) + identity_calls <- 0L + wrong_identity <- FALSE + assignInNamespace("mx_whoami", function(session) { + identity_calls <<- identity_calls + 1L + list(user_id = ids[1], device_id = if (wrong_identity) "OTHER" else "DEVICE") + }, "mx.api") + ctx <- mx.client:::verification_context(cfg, stores[1]) + expect_true(is.environment(ctx)) + expect_equal(identity_calls, 1L) + wrong_identity <- TRUE + expect_error(mx.client:::verification_context(cfg, stores[1]), "different Matrix device") + wrong_identity <- FALSE + current$self_signing_keys[[ids[1]]]$signatures <- list() + expect_error(mx.client:::verification_context(cfg, stores[1]), "self-signing key") + current <- pristine + + # A real encrypted batch survives to the verification queue; its ordinary + # message callback runs only after account, sessions, and cursor are saved. + ak <- mx.crypto::mxc_account_identity_keys(ctx$account) + bk <- mx.crypto::mxc_account_identity_keys(accounts[[2]]) + mx.crypto::mxc_account_generate_one_time_keys(ctx$account, 1L) + recipient <- list(user_id = ids[1], device_id = "DEVICE", + curve25519 = ak$curve25519, ed25519 = ak$ed25519, + otk = mx.crypto::mxc_account_one_time_keys(ctx$account)[[1]]) + room <- "!room:example.org" + out <- mx_crypto_encrypt_for_devices(accounts[[2]], mx_crypto_sessions_new(), + room, request()$content, bk$curve25519, "DEVICE", list(recipient), ids[2]) + normal <- mx_crypto_encrypt_for_devices(accounts[[2]], out$sessions, room, + list(msgtype = "m.text", body = "ordinary fixture"), bk$curve25519, + "DEVICE", list(recipient), ids[2]) + batch <- list(next_batch = "after-verification", to_device = list(events = + lapply(out$to_device, function(p) list(type = "m.room.encrypted", + sender = ids[2], content = p$content))), rooms = list(join = setNames( + list(list(timeline = list(events = lapply(list(out$event, normal$event), + function(c) list(type = "m.room.encrypted", sender = ids[2], + event_id = "$fixture", origin_server_ts = 100000, content = c))))), room))) + sequence <- character() + assignInNamespace("mx_sync", function(...) { + sequence <<- c(sequence, "sync"); batch + }, "mx.api") + assignInNamespace("mx_crypto_account_save", function(...) { + sequence <<- c(sequence, "account"); save_account(...) + }, "mx.client") + assignInNamespace("mx_crypto_sessions_save", function(...) { + sequence <<- c(sequence, "sessions"); save_sessions(...) + }, "mx.client") + assignInNamespace("mx_client_save", function(...) { + sequence <<- c(sequence, "cursor"); save_client(...) + }, "mx.client") + events <- mx.client:::verification_poll(ctx, ids[2], function(messages) { + sequence <<- c(sequence, "messages") + expect_identical(mx_client_load(path = cfg_path)$sync_token, "after-verification") + expect_equal(length(mx_crypto_sessions_load(stores[1])$megolm_in), 1L) + expect_identical(messages[[1]]$body, "ordinary fixture") + }) + expect_identical(sequence, c("sync", "account", "sessions", "cursor", "messages")) + expect_equal(length(events), 1L) + expect_identical(events[[1]]$content$msgtype, "m.key.verification.request") + expect_equal(length(ctx$messages), 1L) + + # Reusing the caller's original object must not rewind the saved cursor. + # The bot and original console may both have advanced it since loading. + expect_null(cfg$sync_token) + retry_ctx <- mx.client:::verification_context(cfg, stores[1]) + expect_identical(retry_ctx$client$sync_token, "after-verification") + resumed_since <- NULL + sync_calls <- 0L + assignInNamespace("mx_sync", function(session, since = NULL, ...) { + sync_calls <<- sync_calls + 1L + resumed_since <<- since + list(next_batch = "after-retry") + }, "mx.api") + mx.client:::verification_poll(retry_ctx, ids[2], function(e) NULL) + expect_equal(sync_calls, 1L) + expect_identical(resumed_since, "after-verification") + expect_identical(mx_client_load(path = cfg_path)$sync_token, "after-retry") + + # A fresh cursor is not permission to silently switch device or server. + saved <- mx_client_load(path = cfg_path) + before_calls <- identity_calls + for (field in c("server", "user_id", "device_id")) { + changed <- saved + changed[[field]] <- paste0(changed[[field]], "-changed") + mx_client_save(changed) + expect_error(mx.client:::verification_context(cfg, stores[1]), + "saved Matrix identity changed") + } + expect_equal(identity_calls, before_calls) + mx_client_save(saved) +}) diff --git a/inst/tinytest/test_sas_own_device.R b/inst/tinytest/test_sas_own_device.R new file mode 100644 index 0000000..cf3004a --- /dev/null +++ b/inst/tinytest/test_sas_own_device.R @@ -0,0 +1,88 @@ +library(tinytest) +if (!requireNamespace("mx.crypto", quietly = TRUE) || + !"mxc_sas_commitment" %in% getNamespaceExports("mx.crypto")) { + exit_file("mx.crypto SAS primitives are unavailable") +} +library(mx.client) +local({ + uid <- "@me:example.org" + store <- tempfile("sas-own-") + on.exit(unlink(store, recursive = TRUE), add = TRUE) + signing <- mx.client:::mx_crypto_cross_signing_new() + a <- mx.crypto::mxc_account_new() + b <- mx.crypto::mxc_account_new() + mx.client:::mx_crypto_cross_signing_save(signing, store) + mx_crypto_account_save(a, store) + mx_crypto_sessions_save(mx_crypto_sessions_new(), store) + objects <- mx.client:::mx_crypto_cross_signing_objects(signing, uid, a, "A") + devices <- list(A = mx_crypto_device_keys(a, uid, "A"), + B = mx_crypto_device_keys(b, uid, "B")) + client <- list(server = "https://example.invalid", token = "fixture", + user_id = uid, device_id = "A") + original_query <- mx.api::mx_keys_query + original_upload <- mx.api::mx_keys_signatures_upload + on.exit({ + assignInNamespace("mx_keys_query", original_query, "mx.api") + assignInNamespace("mx_keys_signatures_upload", original_upload, "mx.api") + }, add = TRUE) + queries <- uploads <- 0L + assignInNamespace("mx_keys_query", function(...) { + queries <<- queries + 1L + list(master_keys = setNames(list(objects$master), uid), + self_signing_keys = setNames(list(objects$self_signing), uid), + user_signing_keys = setNames(list(objects$user_signing), uid), + device_keys = setNames(list(devices), uid)) + }, "mx.api") + assignInNamespace("mx_keys_signatures_upload", function(session, signatures) { + uploads <<- uploads + 1L + expect_identical(names(signatures), uid) + expect_identical(names(signatures[[uid]]), "B") + # /keys/signatures/upload merges signatures into the existing device; + # it does not replace the device object or remove its self-signature. + incoming <- signatures[[uid]]$B$signatures + for (signer in names(incoming)) for (key_id in names(incoming[[signer]])) { + devices$B$signatures[[signer]][[key_id]] <<- incoming[[signer]][[key_id]] + } + list() + }, "mx.api") + request <- list(type = "m.key.verification.request", sender = uid, + content = list(from_device = "B", transaction_id = "t", + timestamp = floor(as.numeric(Sys.time()) * 1000), methods = list("m.sas.v1"))) + sa <- mx_sas_from_request(client, store, request) + sb <- mx_sas_session(uid, "B", sa$peer_keys, uid, "A", sa$keys, "t", initiator = TRUE) + transfer <- function(from, to, device_only = FALSE) { + for (e in mx_sas_outgoing(from)) { + if (device_only && e$type == "m.key.verification.mac") { + e$content$mac <- e$content$mac["ed25519:B"] + # Independent single-device key-list MAC from the Matrix formula. + e$content$keys <- mx.crypto::mxc_sas_mac(from$crypto, "ed25519:B", + paste0("MATRIX_KEY_VERIFICATION_MAC", uid, "B", uid, "A", "tKEY_IDS")) + } + mx_sas_receive(to, list(type = e$type, sender = uid, content = e$content)) + mx_sas_outgoing(from, e$id) + } + } + mx_sas_accept(sa) + for (i in 1:3) {transfer(sa, sb); transfer(sb, sa)} + expect_identical(mx_sas_status(sa)$decimal, mx_sas_status(sb)$decimal) + mx_sas_confirm(sa, TRUE) + mx_sas_confirm(sb, TRUE) + transfer(sa, sb) + transfer(sb, sa, device_only = TRUE) + expect_true(mx_sas_status(sa)$peer_mac_valid) + expect_identical(mx_sas_status(sa)$phase, "verified") + expect_equal(uploads, 0L) + mx_sas_record_trust(sa, client, store) + expect_true(mx_sas_status(sa)$local_trust_recorded) + expect_equal(uploads, 1L) + expect_true(queries >= 3L) + public <- mx.crypto::mxc_signing_key_public(signing$self_signing) + expect_true(mx.client:::mx_crypto_signature_valid(devices$B, uid, public)) + verified <- mx_crypto_known_devices(client, uid, + self_master_key = mx.crypto::mxc_signing_key_public(signing$master)) + target <- Filter(function(d) d$device_id == "B", verified) + expect_equal(length(target), 1L) + expect_true(target[[1]]$cross_signed) + mx_sas_record_trust(sa, client, store) + expect_equal(uploads, 1L) +}) diff --git a/inst/tinytest/test_sas_transport.R b/inst/tinytest/test_sas_transport.R new file mode 100644 index 0000000..5afd850 --- /dev/null +++ b/inst/tinytest/test_sas_transport.R @@ -0,0 +1,166 @@ +library(tinytest) +if (!requireNamespace("mx.crypto", quietly = TRUE) || + !"mxc_sas_commitment" %in% getNamespaceExports("mx.crypto")) { + exit_file("mx.crypto SAS primitives are unavailable") +} +library(mx.client) + +local({ + a <- mx.crypto::mxc_account_new() + b <- mx.crypto::mxc_account_new() + ak <- mx.crypto::mxc_account_identity_keys(a) + bk <- mx.crypto::mxc_account_identity_keys(b) + mx.crypto::mxc_account_generate_one_time_keys(b, 1L) + recipient <- list(user_id = "@bob:example.org", device_id = "B", + curve25519 = bk$curve25519, ed25519 = bk$ed25519, + otk = mx.crypto::mxc_account_one_time_keys(b)[[1]]) + room <- "!room:example.org" + relation <- list(rel_type = "m.reference", event_id = "$request") + content <- list(from_device = "A", methods = list("m.sas.v1"), + `m.relates_to` = relation) + out <- mx_crypto_encrypt_for_devices(a, mx_crypto_sessions_new(), room, + content, ak$curve25519, "A", list(recipient), "@alice:example.org", + event_type = "m.key.verification.ready") + expect_identical(out$event$`m.relates_to`, relation) + expect_equal(length(out$to_device), 1L) + event <- list(type = "m.room.encrypted", sender = "@alice:example.org", + event_id = "$ready", origin_server_ts = 100000, content = out$event) + response <- list(to_device = list(events = lapply(out$to_device, function(p) + list(type = "m.room.encrypted", sender = "@alice:example.org", content = p$content))), + rooms = list(join = setNames(list(list(timeline = list(events = list(event)))), room))) + devices <- list(list(user_id = "@alice:example.org", device_id = "A", + curve25519 = ak$curve25519, ed25519 = ak$ed25519)) + res <- mx_crypto_process_sync(b, mx_crypto_sessions_new(), response, + bk$curve25519, "@bob:example.org", devices, "B") + expect_equal(length(res$events), 0L) + expect_equal(length(res$verification_events), 1L) + ready <- res$verification_events[[1]] + expect_identical(ready$type, "m.key.verification.ready") + expect_identical(mx.api::mx_canonical_json(ready$content), + mx.api::mx_canonical_json(content)) + expect_identical(ready$event_id, "$ready") + expect_identical(ready$origin_server_ts, event$origin_server_ts) + expect_true(ready$sender_verified) + + # Matrix Dart SDK moves the relation outside the encrypted plaintext. + encrypt_outer <- function(inner_room = room, inner_relation = NULL) { + plaintext <- list(type = "m.key.verification.start", room_id = inner_room, + content = list(from_device = "A", method = "m.sas.v1")) + plaintext$content$`m.relates_to` <- inner_relation + event$content$ciphertext <- mx.crypto::mxc_megolm_encrypt( + out$sessions$megolm_out[[room]]$session, + charToRaw(mx.api::mx_canonical_json(plaintext))) + response$to_device$events <- list() + response$rooms$join[[room]]$timeline$events <- list(event) + response + } + processed <- mx_crypto_process_sync(b, res$sessions, encrypt_outer(), + bk$curve25519, "@bob:example.org", devices, "B") + expect_identical(processed$verification_events[[1]]$content$`m.relates_to`, relation) + wrong <- encrypt_outer(inner_relation = list(rel_type = "m.reference", event_id = "$other")) + expect_warning(rejected <- mx_crypto_process_sync(b, res$sessions, wrong, + bk$curve25519, "@bob:example.org", devices, "B"), "conflicting relations") + expect_equal(length(rejected$verification_events), 0L) + # Canonicalization errors from untrusted outer metadata cannot abort sync. + bad <- encrypt_outer(inner_relation = relation) + bad$rooms$join[[room]]$timeline$events[[1]]$content$`m.relates_to`$extra <- 0.5 + good <- encrypt_outer() + bad$rooms$join[[room]]$timeline$events <- c( + bad$rooms$join[[room]]$timeline$events, + good$rooms$join[[room]]$timeline$events) + expect_warning(continued <- mx_crypto_process_sync(b, res$sessions, bad, + bk$curve25519, "@bob:example.org", devices, "B"), "conflicting relations") + expect_equal(length(continued$verification_events), 1L) + expect_identical(continued$verification_events[[1]]$type, "m.key.verification.start") + wrong <- encrypt_outer(inner_room = "!other:example.org") + expect_warning(rejected <- mx_crypto_process_sync(b, res$sessions, wrong, + bk$curve25519, "@bob:example.org", devices, "B"), "different room") + expect_equal(length(rejected$verification_events), 0L) + + # Clear room and to-device requests remain original envelopes, not chat text. + request <- list(type = "m.room.message", sender = "@alice:example.org", + event_id = "$req", origin_server_ts = 100000, + content = list(msgtype = "m.key.verification.request", body = "Verify", + to = "@bob:example.org", from_device = "A", methods = list("m.sas.v1"))) + td <- list(type = "m.key.verification.request", sender = "@alice:example.org", + content = list(from_device = "A", methods = list("m.sas.v1"), + transaction_id = "td", timestamp = 100000)) + response$to_device$events <- list(td) + response$rooms$join[[room]]$timeline$events <- list(request) + plain <- mx_crypto_process_sync(b, res$sessions, response, + bk$curve25519, "@bob:example.org", devices, "B") + expect_equal(length(plain$events), 0L) + expect_equal(length(plain$verification_events), 2L) + expect_identical(plain$verification_events[[1]], td) + expect_identical(plain$verification_events[[2]]$room_id, room) + + # Real Olm-encrypted to-device verification, not just the cleartext branch. + olm <- out$sessions$olm[[bk$curve25519]] + payload <- list(sender = "@alice:example.org", recipient = "@bob:example.org", + keys = list(ed25519 = ak$ed25519), recipient_keys = list(ed25519 = bk$ed25519), + type = td$type, content = td$content) + olm_response <- function(sender = "@alice:example.org") { + msg <- mx.crypto::mxc_olm_encrypt(olm, charToRaw(mx.api::mx_canonical_json(payload))) + list(to_device = list(events = list(list(type = "m.room.encrypted", sender = sender, + content = list(algorithm = "m.olm.v1.curve25519-aes-sha2", + sender_key = ak$curve25519, ciphertext = setNames(list(msg), bk$curve25519)))))) + } + received <- mx_crypto_process_sync(b, res$sessions, olm_response(), + bk$curve25519, "@bob:example.org", devices, "B") + expect_equal(length(received$verification_events), 1L) + expect_identical(received$verification_events[[1]]$type, td$type) + received <- mx_crypto_process_sync(b, res$sessions, olm_response("@mallory:example.org"), + bk$curve25519, "@bob:example.org", devices, "B") + expect_equal(length(received$verification_events), 0L) + + # Verification transport saves ratchets before HTTP and retries the same + # encrypted bytes and ids, leaving unsent key delivery unmarked on disk. + store <- tempfile("sas-send-") + on.exit(unlink(store, recursive = TRUE), add = TRUE) + ctx <- new.env() + ctx$client <- list(server = "https://example.invalid", token = "fixture", + user_id = "@alice:example.org", device_id = "A") + ctx$store_dir <- store + ctx$account <- a + ctx$sessions <- mx_crypto_sessions_new() + ctx$pending <- list() + sequence <- character() + sent <- list() + fail <- TRUE + original <- list(recipients = mx.client:::verification_recipients, + device = mx.api::mx_send_to_device, event = mx.api::mx_send_event) + on.exit({ + assignInNamespace("verification_recipients", original$recipients, "mx.client") + assignInNamespace("mx_send_to_device", original$device, "mx.api") + assignInNamespace("mx_send_event", original$event, "mx.api") + }, add = TRUE) + assignInNamespace("verification_recipients", function(...) list(recipient), "mx.client") + assignInNamespace("mx_send_to_device", function(session, event_type, messages, txn_id) { + sequence <<- c(sequence, "key") + disk <- mx_crypto_sessions_load(store) + expect_equal(length(disk$olm), 1L) + expect_equal(length(disk$megolm_out[[room]]$shared), 0L) + sent[[length(sent) + 1L]] <<- list(id = txn_id, messages = messages) + if (fail) stop("fixture send failure") + list() + }, "mx.api") + assignInNamespace("mx_send_event", function(session, room_id, event_type, content, txn_id) { + sequence <<- c(sequence, "room") + expect_identical(event_type, "m.room.encrypted") + expect_identical(txn_id, "fixed-id") + expect_identical(content$`m.relates_to`, relation) + "$sent" + }, "mx.api") + envelope <- list(id = "fixed-id", user_id = "@bob:example.org", device_id = "B", + room_id = room, type = "m.key.verification.ready", content = content) + expect_error(mx.client:::verification_send(ctx, envelope, TRUE), "fixture send failure") + expect_identical(sequence, "key") + expect_equal(length(ctx$pending), 1L) + expect_equal(length(mx_crypto_sessions_load(store)$megolm_out[[room]]$shared), 0L) + fail <- FALSE + mx.client:::verification_send(ctx, envelope, TRUE) + expect_identical(sequence, c("key", "key", "room")) + expect_identical(sent[[1]], sent[[2]]) + expect_equal(length(ctx$pending), 0L) + expect_identical(mx_crypto_sessions_load(store)$megolm_out[[room]]$shared, bk$curve25519) +}) diff --git a/inst/tinytest/test_user_verification.R b/inst/tinytest/test_user_verification.R new file mode 100644 index 0000000..8fdc8a5 --- /dev/null +++ b/inst/tinytest/test_user_verification.R @@ -0,0 +1,196 @@ +library(tinytest) +if (!requireNamespace("mx.crypto", quietly = TRUE) || + utils::packageVersion("mx.crypto") < "0.2.1.1" || + utils::packageVersion("mx.api") < "0.3.0.2") { + exit_file("cross-signing dependencies are not available") +} +library(mx.client) + +local({ + ids <- c("@alice:example.org", "@bob:example.org") + stores <- c(tempfile("alice-"), tempfile("bob-")) + on.exit(unlink(stores, recursive = TRUE), add = TRUE) + keys <- lapply(ids, function(x) mx.client:::mx_crypto_cross_signing_new()) + accounts <- lapply(ids, function(x) mx.crypto::mxc_account_new()) + clients <- lapply(ids, function(id) list(server = "https://example.invalid", + token = "fixture-token", user_id = id, device_id = "DEVICE")) + objects <- lapply(seq_along(ids), function(i) { + mx.client:::mx_crypto_cross_signing_save(keys[[i]], stores[[i]]) + mx_crypto_account_save(accounts[[i]], stores[[i]]) + mx_crypto_sessions_save(mx_crypto_sessions_new(), stores[[i]]) + mx.client:::mx_crypto_cross_signing_objects( + keys[[i]], ids[[i]], accounts[[i]], "DEVICE") + }) + pins <- vapply(keys, function(k) mx.crypto::mxc_signing_key_public(k$master), + character(1)) + users <- vapply(keys, function(k) + mx.crypto::mxc_signing_key_public(k$user_signing), character(1)) + current <- list( + master_keys = setNames(lapply(objects, `[[`, "master"), ids), + self_signing_keys = setNames(lapply(objects, `[[`, "self_signing"), ids), + user_signing_keys = setNames(lapply(objects, `[[`, "user_signing"), ids), + device_keys = setNames(lapply(seq_along(ids), function(i) { + device <- mx_crypto_device_keys(accounts[[i]], ids[[i]], "DEVICE") + setNames(list(mx.client:::mx_crypto_add_signature( + device, keys[[i]]$self_signing, ids[[i]], paste0("ed25519:", + mx.crypto::mxc_signing_key_public(keys[[i]]$self_signing)))), + "DEVICE") + }), ids)) + pristine <- current + calls <- list() + queries <- 0L + query_names <- NULL + mode <- "ok" + query_original <- mx.api::mx_keys_query + upload_original <- mx.api::mx_keys_signatures_upload + on.exit({ + assignInNamespace("mx_keys_query", query_original, ns = "mx.api") + assignInNamespace("mx_keys_signatures_upload", upload_original, + ns = "mx.api") + }, add = TRUE) + assignInNamespace("mx_keys_query", function(session, device_keys, ...) { + queries <<- queries + 1L + query_names <<- names(device_keys) + result <- current + # The protocol returns only the requesting user's user-signing key. + result$user_signing_keys <- result$user_signing_keys[session$user_id] + result + }, ns = "mx.api") + assignInNamespace("mx_keys_signatures_upload", function(session, signatures) { + calls[[length(calls) + 1L]] <<- signatures + if (mode == "transport") stop("fixture transport failure") + if (mode == "reject") return(list(failures = list(peer = "rejected"))) + if (mode == "drop") return(list()) + for (uid in names(signatures)) { + for (key in names(signatures[[uid]])) { + incoming <- signatures[[uid]][[key]] + current$master_keys[[uid]]$signatures[[session$user_id]] <<- + incoming$signatures[[session$user_id]] + } + } + list(failures = list()) + }, ns = "mx.api") + check <- function(i = 1L) { + j <- 3L - i + mx_crypto_user_trust(clients[[i]], stores[[i]], ids[[j]], pins[[j]]) + } + verify <- function(i = 1L) { + j <- 3L - i + mx_crypto_verify_user(clients[[i]], stores[[i]], ids[[j]], pins[[j]]) + } + devices <- function(pin = pins[[1]]) mx_crypto_known_devices( + clients[[1]], ids[[2]], self_master_key = pin) + store_files <- unlist(lapply(stores, list.files, full.names = TRUE)) + before <- tools::md5sum(store_files) + + # Neither valid device chains nor a read-only check grants user trust. + expect_false(check(1L)$verified) + expect_false(check(2L)$verified) + expect_equal(length(calls), 0L) + expect_equal(queries, 2L) + expect_true(devices()[[1]]$cross_signed) + expect_false(devices()[[1]]$identity_verified) + + # One real Ed25519 signature establishes only Alice -> Bob. + alice <- verify(1L) + expect_true(alice$verified) + expect_identical(alice$signer_user_id, ids[[1]]) + expect_false(check(2L)$verified) + expect_equal(length(calls), 1L) + payload <- calls[[1]][[ids[[2]]]][[pins[[2]]]] + expect_identical(names(calls[[1]]), ids[[2]]) + expect_identical(names(calls[[1]][[ids[[2]]]]), pins[[2]]) + expect_identical(names(payload$signatures), ids[[1]]) + expect_true(mx.crypto::mxc_ed25519_verify(users[[1]], + charToRaw(mx.api::mx_canonical_json(within(payload, rm(signatures)))), + payload$signatures[[ids[[1]]]][[paste0("ed25519:", users[[1]])]])) + expect_identical(verify(1L), alice) + expect_equal(length(calls), 1L) + expect_true(devices()[[1]]$identity_verified) + expect_identical(query_names, rev(ids)) + expect_equal(length(devices()), 1L) + expect_false(devices(NULL)[[1]]$identity_verified) + expect_false(devices(pins[[2]])[[1]]$identity_verified) + + # The second account signs independently. Both checks survive reload. + expect_true(verify(2L)$verified) + expect_true(check(1L)$verified) + expect_true(check(2L)$verified) + expect_equal(length(calls), 2L) + complete <- current + expect_identical(tools::md5sum(store_files), before) + + # User trust does not turn an unsigned or substituted device into trusted. + current$device_keys[[ids[[2]]]][["DEVICE"]] <- + mx_crypto_device_keys(accounts[[2]], ids[[2]], "DEVICE") + expect_false(devices()[[1]]$identity_verified) + expect_true(check(1L)$verified) + current <- complete + current$user_signing_keys[[ids[[1]]]]$signatures <- list() + expect_false(devices()[[1]]$identity_verified) + expect_error(verify(), "user-signing key") + expect_equal(length(calls), 2L) + current <- complete + current$master_keys[[ids[[2]]]]$signatures[[ids[[1]]]][[ + paste0("ed25519:", users[[1]])]] <- "***bad-base64***" + expect_false(check()$verified) + expect_false(devices()[[1]]$identity_verified) + expect_true(verify()$verified) + expect_equal(length(calls), 3L) + + # A coherent replacement identity still cannot replace either pin. + current <- complete + forged <- mx.client:::mx_crypto_cross_signing_new() + replacement <- mx.client:::mx_crypto_cross_signing_objects( + forged, ids[[2]], accounts[[2]], "DEVICE") + current$master_keys[[ids[[2]]]] <- replacement$master + expect_error(verify(), "peer master key differs") + expect_warning(replaced_devices <- devices(), "invalid cross-signing chain") + expect_false(replaced_devices[[1]]$identity_verified) + current <- complete + current$master_keys[[ids[[1]]]] <- mx.client:::mx_crypto_cross_signing_objects( + forged, ids[[1]], accounts[[1]], "DEVICE")$master + expect_error(verify(), "local and homeserver master keys differ") + expect_false(devices()[[1]]$identity_verified) + current <- complete + current$failures <- list("example.org" = list(errcode = "M_TIMEOUT")) + expect_error(verify(), "could not reach") + expect_equal(length(calls), 3L) + + # Transport success alone must never report verified. + current <- pristine + mode <- "reject" + expect_error(verify(), "rejected") + expect_false(check()$verified) + mode <- "drop" + expect_error(verify(), "not confirmed") + expect_false(check()$verified) + mode <- "transport" + expect_error(verify(), "fixture transport failure") + expect_false(check()$verified) + mode <- "ok" + expect_true(verify()$verified) + expect_equal(length(calls), 7L) + + # Refuse incomplete stores and invalid inputs before any network I/O. + nquery <- queries + empty <- tempfile("missing-identity-") + on.exit(unlink(empty, recursive = TRUE), add = TRUE) + expect_error(mx_crypto_verify_user(clients[[1]], empty, ids[[2]], pins[[2]]), + "existing cross-signing store") + expect_false(dir.exists(empty)) + dir.create(empty) + file.copy(file.path(stores[[1]], "cross-signing.json"), empty) + expect_error(mx_crypto_verify_user(clients[[1]], empty, ids[[2]], pins[[2]]), + "existing cross-signing store") + expect_false(file.exists(file.path(empty, "pickle.key"))) + expect_error(mx_crypto_verify_user(clients[[1]], stores[[1]], ids[[1]], pins[[1]]), + "different Matrix user") + for (pin in list(NULL, NA_character_, "short", c(pins[[1]], pins[[2]]))) { + expect_error(mx_crypto_verify_user(clients[[1]], stores[[1]], ids[[2]], pin), + "full independently verified") + } + expect_equal(queries, nquery) + expect_equal(length(calls), 7L) + expect_identical(tools::md5sum(store_files), before) +}) diff --git a/man/mx_crypto_encrypt_event.Rd b/man/mx_crypto_encrypt_event.Rd index 1ae5d56..6fb6f74 100644 --- a/man/mx_crypto_encrypt_event.Rd +++ b/man/mx_crypto_encrypt_event.Rd @@ -8,7 +8,8 @@ mx_crypto_encrypt_event( content, room_id, sender_curve25519, - device_id + device_id, + event_type = "m.room.message" ) } \arguments{ @@ -22,6 +23,9 @@ mx_crypto_encrypt_event( \item{sender_curve25519}{Character. This device's Curve25519 key.} \item{device_id}{Character. This device's id.} + +\item{event_type}{Inner Matrix event type. Defaults to m.room.message; +verification replies use their m.key.verification.* event type.} } \value{ A named list: \code{m.room.encrypted} content. diff --git a/man/mx_crypto_encrypt_for_devices.Rd b/man/mx_crypto_encrypt_for_devices.Rd index 390bbe6..a51338e 100644 --- a/man/mx_crypto_encrypt_for_devices.Rd +++ b/man/mx_crypto_encrypt_for_devices.Rd @@ -11,7 +11,8 @@ mx_crypto_encrypt_for_devices( sender_curve25519, device_id, recipients = list(), - sender_user_id = NULL + sender_user_id = NULL, + event_type = "m.room.message" ) } \arguments{ @@ -35,6 +36,8 @@ exactly this shape for devices whose keys verified.} \item{sender_user_id}{Character. This user's Matrix id. Required to build a spec-conformant Olm payload that the recipient can attribute.} + +\item{event_type}{Inner Matrix event type, defaulting to m.room.message.} } \value{ List with \code{to_device} (per-device payloads), \code{event} diff --git a/man/mx_crypto_known_devices.Rd b/man/mx_crypto_known_devices.Rd index b0b0806..65d27d5 100644 --- a/man/mx_crypto_known_devices.Rd +++ b/man/mx_crypto_known_devices.Rd @@ -26,7 +26,12 @@ checked for internal consistency only.} } \value{ List of verified devices, each \code{list(user_id, device_id, - curve25519, ed25519, cross_signed, master_key)}. \code{master_key} is + curve25519, ed25519, cross_signed, master_key, identity_verified)}. + \code{identity_verified} additionally requires the locally pinned master + for this user, or its authenticated user-signing signature on another + user's master, followed by that user's self-signing/device chain. This + metadata does not change recipient policy or the existing device-level + meaning of \code{sender_verified}. \code{master_key} is the server-reported key from a valid chain, even if it fails the pin. } \description{ diff --git a/man/mx_crypto_process_sync.Rd b/man/mx_crypto_process_sync.Rd index 5ecbf2d..a5753e7 100644 --- a/man/mx_crypto_process_sync.Rd +++ b/man/mx_crypto_process_sync.Rd @@ -41,6 +41,8 @@ local \code{self_master_key}; they never verify the original sender.} } \value{ 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. diff --git a/man/mx_crypto_user_trust.Rd b/man/mx_crypto_user_trust.Rd new file mode 100644 index 0000000..04d41e2 --- /dev/null +++ b/man/mx_crypto_user_trust.Rd @@ -0,0 +1,34 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_crypto_user_trust} +\alias{mx_crypto_user_trust} +\title{Check a directional Matrix identity verification} +\usage{ +mx_crypto_user_trust(client, store_dir, user_id, master_key) +} +\arguments{ +\item{client}{Matrix client config for the account whose trust is checked.} + +\item{store_dir}{Character. That account's existing cross-signing store.} + +\item{user_id}{Character. The other user's full Matrix id.} + +\item{master_key}{Character. The other user's full, unpadded base64 master +public key, authenticated through a trusted independent channel. Do not +obtain this pin solely from the homeserver response being checked.} +} +\value{ +A list with user_id, master_key, signer_user_id, signer_master_key, + and verified. FALSE means the expected identities were found but the + directional trust signature was absent or invalid. Key mismatches error. +} +\description{ +Reads the homeserver's signature on another user's master key and verifies +it through this user's locally pinned master and user-signing keys. Both +identities must match their expected keys. This does not upload signatures, +initialize a store, run sync, or modify encryption sessions. +} +\examples{ +\dontrun{ +mx_crypto_user_trust(client, existing_store, "@peer:example.org", peer_pin) +} +} diff --git a/man/mx_crypto_verify_user.Rd b/man/mx_crypto_verify_user.Rd new file mode 100644 index 0000000..b530449 --- /dev/null +++ b/man/mx_crypto_verify_user.Rd @@ -0,0 +1,41 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_crypto_verify_user} +\alias{mx_crypto_verify_user} +\title{Verify another Matrix user's independently authenticated identity} +\usage{ +mx_crypto_verify_user(client, store_dir, user_id, master_key) +} +\arguments{ +\item{client}{Matrix client config for the account granting trust.} + +\item{store_dir}{Character. That account's existing cross-signing store.} + +\item{user_id}{Character. The other user's full Matrix id.} + +\item{master_key}{Character. The other user's full master public key, +authenticated independently of the homeserver being queried.} +} +\value{ +The same status list as \code{mx_crypto_user_trust()}, with verified + TRUE, invisibly. An error is raised if read-back does not confirm trust. +} +\description{ +Signs the other user's pinned master key with this account's existing +user-signing key. Uploads only a missing or invalid signature, then queries +it back and verifies it before reporting success. No private keys are sent. +A successful upload followed by a failed read-back may already have recorded +trust; rerunning with the same authenticated key is safe. +} +\details{ +This establishes one direction only. For mutual verification, preflight +both accounts with \code{mx_crypto_user_trust()}, then call this function +once as each account using the other account's independently checked pin. +The two uploads are not atomic. No SAS handshake or history-key sharing is +performed, and devices still need their own valid self-signing chains. + +} +\examples{ +\dontrun{ +mx_crypto_verify_user(client, existing_store, "@peer:example.org", peer_pin) +} +} diff --git a/man/mx_sas_accept.Rd b/man/mx_sas_accept.Rd new file mode 100644 index 0000000..217d6f3 --- /dev/null +++ b/man/mx_sas_accept.Rd @@ -0,0 +1,18 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_sas_accept} +\alias{mx_sas_accept} +\title{Accept an incoming SAS verification request} +\usage{ +mx_sas_accept(sas, now = Sys.time()) +} +\arguments{ +\item{sas}{An in-memory SAS transaction.} + +\item{now}{Current time.} +} +\value{ +The transaction, invisibly. A ready event is queued, not sent. +} +\description{ +Accept an incoming SAS verification request +} diff --git a/man/mx_sas_cancel.Rd b/man/mx_sas_cancel.Rd new file mode 100644 index 0000000..c653fe7 --- /dev/null +++ b/man/mx_sas_cancel.Rd @@ -0,0 +1,18 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_sas_cancel} +\alias{mx_sas_cancel} +\title{Cancel a SAS transaction without granting trust} +\usage{ +mx_sas_cancel(sas, code = "m.user") +} +\arguments{ +\item{sas}{An in-memory SAS transaction.} + +\item{code}{Matrix cancellation code.} +} +\value{ +The transaction, invisibly. At most one cancellation is queued. +} +\description{ +Cancel a SAS transaction without granting trust +} diff --git a/man/mx_sas_confirm.Rd b/man/mx_sas_confirm.Rd new file mode 100644 index 0000000..50d0286 --- /dev/null +++ b/man/mx_sas_confirm.Rd @@ -0,0 +1,23 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_sas_confirm} +\alias{mx_sas_confirm} +\title{Confirm or reject the displayed Matrix SAS} +\usage{ +mx_sas_confirm(sas, matches, now = Sys.time()) +} +\arguments{ +\item{sas}{An in-memory SAS transaction displaying a SAS.} + +\item{matches}{One explicit TRUE or FALSE, without a default.} + +\item{now}{Current time.} +} +\value{ +The transaction, invisibly. +} +\description{ +Call only after the human compares the display through a trusted channel. +TRUE queues MACs for this device and its master key. Both a valid peer MAC +and local confirmation are required before the phase becomes verified. +This alone does not upload cross-signing signatures. +} diff --git a/man/mx_sas_console.Rd b/man/mx_sas_console.Rd new file mode 100644 index 0000000..396951c --- /dev/null +++ b/man/mx_sas_console.Rd @@ -0,0 +1,32 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_sas_console} +\alias{mx_sas_console} +\title{Compare a Matrix SAS through a trusted interactive console} +\usage{ +mx_sas_console(sas, receive, send, complete, input = sas_console_read) +} +\arguments{ +\item{sas}{An in-memory transaction from mx_sas_from_request().} + +\item{receive}{Function with no arguments returning a list of original +Matrix events from the sole event consumer. It should wait briefly.} + +\item{send}{Function accepting one outgoing envelope. It must throw on +failure and use the envelope's id for idempotent retries.} + +\item{complete}{Function accepting sas, recording and checking durable trust +with mx_sas_record_trust(), outside the crypto commit window.} + +\item{input}{Function taking a prompt. The default requires an interactive +console; replacement is intended for a trusted human UI or isolated tests.} +} +\value{ +A status list, invisibly. Interrupts cancel and attempt notification. +} +\description{ +Drives one transaction using the application's existing event consumer. +No second sync loop is created by this function. The human must explicitly +type yes after comparing all seven emoji or all three numbers with the peer. +Empty input, no, or cancellation grants no trust. Do not connect the input +callback to an LLM or a Matrix room. +} diff --git a/man/mx_sas_from_request.Rd b/man/mx_sas_from_request.Rd new file mode 100644 index 0000000..f3ebaf0 --- /dev/null +++ b/man/mx_sas_from_request.Rd @@ -0,0 +1,24 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_sas_from_request} +\alias{mx_sas_from_request} +\title{Create a SAS transaction from an incoming verification request} +\usage{ +mx_sas_from_request(client, store_dir, event, now = Sys.time()) +} +\arguments{ +\item{client}{This device's Matrix client config.} + +\item{store_dir}{This device's existing crypto store.} + +\item{event}{Original request envelope from the existing event consumer.} + +\item{now}{Current time.} +} +\value{ +An in-memory SAS transaction, or NULL for an irrelevant/expired request. +} +\description{ +Validates the request's age, recipient, device and method, then reads a +fixed key snapshot. No sync, transport, new identity, or trust upload occurs. +Requests are accepted only when the operator calls mx_sas_accept(). +} diff --git a/man/mx_sas_outgoing.Rd b/man/mx_sas_outgoing.Rd new file mode 100644 index 0000000..2dc50b9 --- /dev/null +++ b/man/mx_sas_outgoing.Rd @@ -0,0 +1,21 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_sas_outgoing} +\alias{mx_sas_outgoing} +\title{Read or acknowledge queued SAS protocol messages} +\usage{ +mx_sas_outgoing(sas, acknowledge = character()) +} +\arguments{ +\item{sas}{An in-memory SAS transaction.} + +\item{acknowledge}{Character vector of successfully sent queue ids.} +} +\value{ +A list of pending envelopes, containing type, content, destination, + and a stable transport id. No private key material is included. +} +\description{ +Send outside any cryptographic state commit window. Acknowledge an id +only after transport succeeds. Retries retain the same id and payload. +Never display SAS codes in the Matrix conversation being verified. +} diff --git a/man/mx_sas_receive.Rd b/man/mx_sas_receive.Rd new file mode 100644 index 0000000..11f832a --- /dev/null +++ b/man/mx_sas_receive.Rd @@ -0,0 +1,25 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_sas_receive} +\alias{mx_sas_receive} +\title{Receive one standard Matrix SAS protocol event} +\usage{ +mx_sas_receive(sas, event = NULL, now = Sys.time()) +} +\arguments{ +\item{sas}{An in-memory SAS transaction.} + +\item{event}{A Matrix event envelope with type, sender, content, and +room_id for in-room verification, or NULL.} + +\item{now}{Current time.} +} +\value{ +The transaction, invisibly. +} +\description{ +Ignores other senders, rooms, devices, and transaction ids. Invalid +messages in this transaction cancel it. Duplicate identical messages do +not repeat operations; conflicting repeats cancel. NULL checks timeouts. +Feed decrypted original event type and content, not flattened chat text. +No transport or persistent trust operation occurs here. +} diff --git a/man/mx_sas_record_trust.Rd b/man/mx_sas_record_trust.Rd new file mode 100644 index 0000000..fdb6103 --- /dev/null +++ b/man/mx_sas_record_trust.Rd @@ -0,0 +1,25 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_sas_record_trust} +\alias{mx_sas_record_trust} +\title{Record trust after a human-confirmed, authenticated SAS exchange} +\usage{ +mx_sas_record_trust(sas, client, store_dir, now = Sys.time()) +} +\arguments{ +\item{sas}{A SAS transaction with both human confirmation and valid peer MACs.} + +\item{client}{This device's Matrix client config.} + +\item{store_dir}{This device's existing cross-signing store.} + +\item{now}{Current time.} +} +\value{ +The transaction, invisibly. Errors leave completion retryable. +} +\description{ +Rechecks the fixed identity snapshot, signs the peer master with this +user's user-signing key (or its own peer device with its self-signing key), +and verifies server read-back. Only then is done queued. A peer's done +acknowledgement does not expose or prove its private user-signing key. +} diff --git a/man/mx_sas_session.Rd b/man/mx_sas_session.Rd new file mode 100644 index 0000000..5120b7f --- /dev/null +++ b/man/mx_sas_session.Rd @@ -0,0 +1,53 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_sas_session} +\alias{mx_sas_session} +\title{Create an in-memory Matrix SAS transaction} +\usage{ +mx_sas_session( + user_id, + device_id, + keys, + peer_user_id, + peer_device_id, + peer_keys, + transaction_id, + room_id = NULL, + initiator = FALSE, + now = Sys.time() +) +} +\arguments{ +\item{user_id}{This account's Matrix user id.} + +\item{device_id}{This account's device id.} + +\item{keys}{Named list of this device's Ed25519 key and locally trusted +master key. Names are ed25519:device_id and ed25519:master_public_key.} + +\item{peer_user_id}{The selected peer user id.} + +\item{peer_device_id}{The selected peer device id.} + +\item{peer_keys}{Named list containing the peer device and master public +keys fetched and validated before the handshake. SAS authenticates this +exact snapshot, not later replacements from a homeserver.} + +\item{transaction_id}{Initial request event id for room verification, +or the initial to-device request's transaction_id.} + +\item{room_id}{Room id, or NULL for to-device verification.} + +\item{initiator}{TRUE if this device sent the initial request.} + +\item{now}{Current time. Injectable for deterministic timeout tests.} +} +\value{ +An opaque in-memory transaction. Use mx_sas_status() for display. +} +\description{ +Holds a fixed snapshot of both parties' device and master public keys. +Feed this object events from the application's existing event consumer; +this function does not start sync, send messages, or record trust. +Ephemeral state cannot be persisted. Restart interrupted transactions +with a new request id and new session. Only modern SAS algorithms are used. +} diff --git a/man/mx_sas_start.Rd b/man/mx_sas_start.Rd new file mode 100644 index 0000000..de6884e --- /dev/null +++ b/man/mx_sas_start.Rd @@ -0,0 +1,18 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_sas_start} +\alias{mx_sas_start} +\title{Begin the SAS key agreement after request negotiation} +\usage{ +mx_sas_start(sas, now = Sys.time()) +} +\arguments{ +\item{sas}{An in-memory SAS transaction in the ready phase.} + +\item{now}{Current time.} +} +\value{ +The transaction, invisibly. A start event is queued, not sent. +} +\description{ +Begin the SAS key agreement after request negotiation +} diff --git a/man/mx_sas_status.Rd b/man/mx_sas_status.Rd new file mode 100644 index 0000000..d30c75a --- /dev/null +++ b/man/mx_sas_status.Rd @@ -0,0 +1,20 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_sas_status} +\alias{mx_sas_status} +\title{Inspect a Matrix SAS verification transaction} +\usage{ +mx_sas_status(sas) +} +\arguments{ +\item{sas}{An in-memory SAS transaction.} +} +\value{ +A list with identities, phase, comparison values, local confirmation, + peer MAC validity, local trust status, peer completion, and cancellation code. + cancel_detail identifies a locally diagnosed missing peer master proof. +} +\description{ +Display codes only on the operator's trusted console, never in the Matrix +conversation being verified. A verified SAS is distinct from a recorded +local trust signature and from the peer's completion acknowledgement. +} diff --git a/man/mx_verify_console.Rd b/man/mx_verify_console.Rd new file mode 100644 index 0000000..7e1ff89 --- /dev/null +++ b/man/mx_verify_console.Rd @@ -0,0 +1,65 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_verify_console} +\alias{mx_verify_console} +\title{Verify a Matrix client interactively from an R console} +\usage{ +mx_verify_console( + client, + store_dir, + peer_user_id, + room_id = NULL, + exclusive = FALSE, + timeout = 120, + on_messages = verification_print_messages +) +} +\arguments{ +\item{client}{Client loaded with mx_client_load() from its existing config.} + +\item{store_dir}{This device's existing, initialized crypto store.} + +\item{peer_user_id}{Full Matrix id of the person being verified.} + +\item{room_id}{Exact room id, or NULL for to-device requests.} + +\item{exclusive}{Must explicitly be TRUE after stopping other consumers +of this device and store. FALSE refuses before any network or store access.} + +\item{timeout}{Maximum seconds to wait for an initial request, 1 to 600.} + +\item{on_messages}{Function receiving ordinary messages read while the +console owns sync. The default reports only their count, keeping untrusted +chat text out of the verification prompts; content is returned in messages.} +} +\value{ +Invisibly, a list with status, updated client, and ordinary messages. +} +\description{ +Owns the device's existing sync cursor and crypto store while waiting for +a verification request from the selected user and room. In the other +client, choose Start verification, then compare the emoji or numbers in +its UI with this trusted console. No private client database, desktop +keyring, or remote user's private signing key is read. +} +\details{ +Stop any bot or other process using this device before calling. The +exclusive argument is an explicit operator assertion, not a process lock. +Restart that process only after this call returns. Ordinary messages read +during this session go to on_messages and are returned for review; a bot +will not automatically process messages whose cursor the console advanced. +Existing event-loop hosts can instead use mx_sas_from_request() and +mx_sas_console() with their own receive/send callbacks. +Each attempt reloads the saved cursor and credentials, refusing a changed +server, user, or device identity rather than replaying the caller's old cursor. + +} +\examples{ +\dontrun{ +# First stop the service using this exact device and crypto store. +client <- mx_client_load(path = config_path) +result <- mx_verify_console(client, existing_store, + "@peer:example.org", "!room:example.org", exclusive = TRUE) +# In the peer's Matrix app, choose Start verification. +# Restart the service after the console returns. +} +} diff --git a/vignettes/e2ee.md b/vignettes/e2ee.md index 7877141..147bc36 100644 --- a/vignettes/e2ee.md +++ b/vignettes/e2ee.md @@ -37,14 +37,17 @@ Read this first; it frames what the rest of the vignette delivers. This cannot recover another user's historical outbound session, and decrypted history from a forwarded key never reports its original sender as verified. -- **No SAS (emoji) verification.** +- **Interactive SAS verification.** With mx.crypto >= 0.2.1.2, an explicit + human comparison and valid MACs authenticate a fixed snapshot of both + device and master keys. Trust signatures are uploaded and checked only + after those requirements pass. QR verification is not implemented. - **Local-key storage.** Ratchet state is pickled with a locally stored 32-byte key (file mode 0600). That guards against casual inspection; it is only as strong as access to the local filesystem. No passphrase or hardware backing. -This matches what bots and controlled deployments need. For human-grade -verification flows, watch the `mx.crypto` roadmap. +The console procedure below uses the same Matrix verification messages as +other clients. It does not require access to their private databases. ## The pieces @@ -116,6 +119,211 @@ Cross-signed is not the same as trusted. Another client must independently verify the master identity before treating its signature chain as belonging to the expected person. +## Interactive SAS verification + +Requires mx.client >= 0.2.0.10 and mx.crypto >= 0.2.1.2. Older crypto builds +can still perform existing E2EE operations, but the SAS entry points refuse +with an upgrade message. The handshake negotiates `m.sas.v1`, +`curve25519-hkdf-sha256`, `sha256`, and `hkdf-hmac-sha256.v2`. It supports +in-room and to-device requests, emoji and decimal displays, cancellation, +timeouts, simultaneous starts, commitment checks, and both device/master MACs. + +For a bot, stop the service that owns its Matrix device. Open an interactive +R console on that host, load its exact existing config and crypto store, +and run: + +```r +client <- mx_client_load(path = config_path) +result <- mx_verify_console(client, existing_crypto_store, + "@peer:example.org", "!room:example.org", exclusive = TRUE) +``` + +`config_path` and `existing_crypto_store` are paths supplied by the operator, +not new stores. Cross-signing must already be bootstrapped for this identity. +The function refuses missing stores or mismatched local/published keys. It +does not create accounts, reset keys, change room membership, or read a +FluffyChat database. The other user's app can run on another computer; an +SSH terminal is sufficient for the R side. That app owns its own signing +keys and may ask its user to unlock them through its normal interface. + +In the peer's app, choose **Start verification**. Accept in R, compare all +7 emoji (with labels) or all 3 numbers against the app, and explicitly +confirm in both interfaces. Empty input is rejection. Compare in person, +on your own two screens, or over a trusted independent channel. Never put +the comparison in the Matrix conversation being verified. An LLM must not +supply the confirmation. A peer that authenticates only its device key, +without its master key, is refused for cross-user verification. For another +device of this same account, its device MAC is sufficient: this account +already has a trusted local master and signs the newly authenticated device. +Some clients omit their own master proof until that identity is locally +verified. If the console reports `cancel_detail = "peer_master_missing"`, +restore or verify the existing identity through that client's normal recovery +interface before retrying. Matching emoji alone do not authenticate an omitted +master key. Never accept an unproven master or reset an identity implicitly. +An owner-approved identity replacement is a separate recovery decision, +described below. + +After the call returns, inspect `result$status`. `local_trust_recorded` +means this account's signature was confirmed by read-back. `peer_done` +means the other client acknowledged completion; it is not a read-back of +the other user's private user-signing key. A cancelled or timed-out flow +can still have recorded local trust if cancellation happened after that +upload. Report that partial state, then recheck before repeating. Interrupted +ephemeral handshakes cannot be restored; start a new verification request. +Each console attempt reloads its saved config so a reused R client object +cannot rewind the cursor to an earlier attempt. Changed server, user, or +device identities are refused before network or crypto-store operations. + +Restart the bot only after the console returns. `exclusive = TRUE` asserts +that you stopped other consumers; it is not a process lock. The console +advances the same cursor after saving crypto state. Ordinary messages read +during the session are passed to `on_messages` and returned in +`result$messages`. The default reports counts rather than interleaving +untrusted chat text with the verification prompts. A paused bot will not +automatically process those messages. This is a temporary console takeover, +not an always-on bot verification UI. + +### Peer identity recovery in FluffyChat + +These steps describe the FluffyChat 2.9.1 interface with Matrix Dart SDK +10.2.0. Labels and recovery behavior may differ in other builds. + +Start in the correct account and room. In a multi-account app, close any open +recovery page, select the intended account, and confirm its full Matrix ID in +Settings before reopening **Chat backup**. Keep that account selected through +any authentication prompt. FluffyChat exposes **Start verification** in an +encrypted direct chat; a room with 2 members is not necessarily marked as a +direct chat. Use the existing DM with the peer. Do not create a new room or +enable encryption merely to make a missing button appear. + +The app displays several independent states: + +| Display | What it establishes | +|---|---| +| Signed or verified device | Trust in that device's keys, not necessarily recovery of the account's signing keys. | +| Verified account identity | Trust in the account's master key. Cross-user SAS authenticates this key as well as the device. | +| Known since | When the identity was first seen, not independent authentication. | +| Encrypted room | Encryption is enabled; a readable new message and reply are separate delivery checks. | + +**Restore Crypto Identity** means required recovery secrets are unavailable +locally. It can reflect a missing backup secret even when some signing keys +are present. It does not prove all keys are lost. First use the existing +recovery key or passphrase, or another device that still has the identity. +If automatic verification does not appear, inspect **Settings → Devices** +and start verification with the specific other device. A verified-device +badge does not prevent that action. Compare and confirm on both screens. +Skipping a recovery prompt may permit device-only verification; it does not +recreate missing signing keys. Device names need not identify a unique host, +and an old **Last active** timestamp alone does not prove a device is unused. + +If no recovery route works, the account owner may separately authorize a +new crypto identity after accepting the history and trust consequences. +In the interface above, the route is **Settings → Chat backup → Restore +Crypto Identity → Reset account**, followed by the crypto-identity reset +screen. Confirm that it is the crypto identity being reset, not account +deletion. This replaces the cross-signing keys, secret storage, and backup +version. It preserves the current login, device, and locally held room keys; +existing local room keys are queued for the new backup. History whose keys +exist only in an inaccessible old backup may remain unreadable. Do not use +**Export session and wipe device** for this procedure. + +| Credential | Purpose | +|---|---| +| Matrix account login password | Authorizes publishing replacement identity keys when the server asks for password authentication. | +| Crypto recovery key or backup passphrase | Opens the account's encrypted secret storage. Save the new recovery material securely after a reset. | +| Computer or desktop keyring password | Opens local operating-system storage; it is not the Matrix login password. | + +Keep these credentials local. Do not paste them into chat or a task record. +Reset only the selected account, once. Other sessions of that account should +restore the new identity using the new recovery material, not reset it again. +Another account in the same app must remain untouched. Existing sessions may +continue reading encrypted conversations without logging in again; that +does not establish that they have recovered the new signing keys. + +After the peer's identity is ready, use a fresh verification request in the +R console. Load the intended package builds and retain the existing config, +device, and store. Do not rewind sync or delete state after a cancellation. +After matching and confirming on both screens, inspect: + +```r +result$status[c("phase", "local_trust_recorded", "peer_done")] +# Expected completion: phase "done", local_trust_recorded TRUE, peer_done TRUE. +``` + +This confirms local trust read-back and the peer's completion acknowledgement, +not independent inspection of the peer's private signing state. End the +console takeover before restarting the bot, then send a new normal message +and confirm a readable reply. That tests live delivery separately from SAS. +Messages consumed during console verification are in `result$messages` and +will not automatically replay to the bot. + +### Integrating an existing event loop + +`mx_crypto_process_sync()` returns original verification envelopes in +`verification_events`, separate from normalized chat messages. Save its +account/session state and cursor before any verification transport or trust +upload. Create a transaction with `mx_sas_from_request()` and pass further +events to `mx_sas_receive()`. `mx_sas_outgoing()` exposes a stable outbox; +acknowledge each id only after a successful send. In encrypted rooms retain +the original event type using `event_type` in the encryption helpers, and +carry `m.relates_to` outside the ciphertext too. + +`mx_sas_console(sas, receive, send, complete)` supplies the trusted human +prompts around those hooks. `receive` must use the application's existing +consumer; do not add a second `/sync` reader. `complete` calls +`mx_sas_record_trust()` only after explicit comparison and peer MAC checks. +Keep a transaction in memory, expire stale transactions, and cancel multiple +simultaneous requests from the same peer. SAS success does not change +forwarded-key admission or make missing historical room keys available. + +## Mutual verification from an R console + +With mx.client 0.2.0.10, two existing identities can verify each other without +an emoji exchange. Authenticate each full master public key through a trusted +independent channel, such as the other account's locally controlled console. +Do not copy the key from `/keys/query` and pass it back as its own proof. +The [Matrix cross-signing specification](https://spec.matrix.org/latest/client-server-api/#cross-signing) +defines user-to-user trust as a user-signing signature on the other master. + +The following assumes `alice` and `bob` are existing client configs, their +stores already contain the matching private cross-signing keys, and +`alice_pin` and `bob_pin` have been authenticated independently. It does not +log in, generate identities, or recover another application's private keys. + +```r +# Preflight both sides before making either trust change. +a <- mx_crypto_user_trust(alice, alice_store, bob$user_id, bob_pin) +b <- mx_crypto_user_trust(bob, bob_store, alice$user_id, alice_pin) + +# Each account signs separately with its own existing user-signing key. +mx_crypto_verify_user(alice, alice_store, bob$user_id, bob_pin) +mx_crypto_verify_user(bob, bob_store, alice$user_id, alice_pin) + +a <- mx_crypto_user_trust(alice, alice_store, bob$user_id, bob_pin) +b <- mx_crypto_user_trust(bob, bob_store, alice$user_id, alice_pin) +stopifnot(isTRUE(a$verified), isTRUE(b$verified)) +``` + +The check is read-only. The verifier uploads only a missing or invalid public +signature, then verifies its read-back; it never saves ratchet state, runs +`/sync`, or changes the local identity. The two uploads are not atomic: if the +second fails, the first can remain recorded. Recheck both sides and rerun +with the same independently authenticated pins. A replaced identity requires +a new explicit authentication decision, not an automatic re-pin. + +If one account belongs to another client, unlock and use that client's +existing signing keys through a supported path. A password or access token +alone cannot supply its private user-signing key. Do not reset its identity +or send its recovery key through chat to complete this procedure. + +`mx_crypto_known_devices(..., self_master_key = alice_pin)` reports +`identity_verified` only when a device's self-signing chain reaches a master +authenticated by Alice's pin and trust signature. Unsigned devices remain +unverified even when their account's master is trusted. This field does not +change the existing recipient policy, `sender_verified` device-binding +semantics, or same-user-only forwarded-room-key admission. No SAS transaction +is completed, so an already-open interactive verification dialog is separate. + ## Sending ```r