diff --git a/.github/check-results.R b/.github/check-results.R index d71069f..b7609b2 100644 --- a/.github/check-results.R +++ b/.github/check-results.R @@ -10,7 +10,7 @@ cat(status, "\n") 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", + utils::packageVersion("mx.crypto") >= "0.2.2", "mxc_sas_commitment" %in% getNamespaceExports("mx.crypto")) cat("Testing checked build:", find.package(pkg), expected, "\n") cat("Crypto build:", find.package("mx.crypto"), diff --git a/.github/install-matrix-deps.R b/.github/install-matrix-deps.R index 8546e64..77b8d7c 100644 --- a/.github/install-matrix-deps.R +++ b/.github/install-matrix-deps.R @@ -14,7 +14,8 @@ 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", +utils::install.packages("mx.api", + repos = c("https://cran.r-project.org", "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) @@ -25,5 +26,5 @@ for (pkg in names(floors)) { } message(pkg, " ", utils::packageVersion(pkg), " at ", find.package(pkg)) } -stopifnot(utils::packageVersion("mx.crypto") >= "0.2.1.2", +stopifnot(utils::packageVersion("mx.crypto") >= "0.2.2", "mxc_sas_commitment" %in% getNamespaceExports("mx.crypto")) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 563990b..7c0eb83 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,21 +24,22 @@ jobs: with: backend: RAPT - # Keep older E2EE dependency floors in DESCRIPTION. SAS needs the - # reviewed crypto build below until it is available on CRAN/drat. + - name: Dependencies + run: ./run.sh install_deps + + # Install these AFTER generic dependencies, which may select stale binaries. + # DESCRIPTION declares the SAS crypto floor. Keep the tested, + # immutable source pin while CRAN mirrors finish updating. # Disable rapt for these source installs so it cannot substitute an - # older binary. The helper reads the base floors from DESCRIPTION. + # older binary. The helper reads the 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" + # mx.crypto 0.2.2, with the reviewed SAS implementation. + MX_CRYPTO_REF: "5f67d5531fec8f476f20345d70d95cfc9eaaa502" run: | Rscript -e 'install.packages(c("curl", "jsonlite"))' Rscript .github/install-matrix-deps.R - - name: Dependencies - run: ./run.sh install_deps - # mx.crypto above builds from its vendored Rust sources using the # hosted runner's Rust toolchain. simplermarkdown is still needed for # R CMD check to recognize vignettes/e2ee.md as a vignette. diff --git a/DESCRIPTION b/DESCRIPTION index d1c5e24..a84d64e 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,8 +1,8 @@ Package: mx.client Type: Package Title: Stateful Matrix Client Helpers -Version: 0.2.0.10 -Date: 2026-09-10 +Version: 0.2.0.11 +Date: 2026-09-11 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), email = "troy@cornball.ai", @@ -27,7 +27,7 @@ Imports: tools, utils Suggests: - mx.crypto (>= 0.2.1.1), + mx.crypto (>= 0.2.2), simplermarkdown, tinytest VignetteBuilder: simplermarkdown diff --git a/NEWS.md b/NEWS.md index 4a438d9..64cd7e8 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,17 @@ +# mx.client 0.2.0.11 + +* Write schema version 1 in session stores and reject unknown or malformed + versions in all 3 crypto-store loaders, reporting the full file path. + Legacy unversioned session stores remain readable and migrate only on save. +* Keep writing account.pickle as a raw encrypted pickle so older clients + can still open the store. Also accept version-1 JSON account envelopes + without rewriting them on load; envelope writes are deferred. +* Raise the optional mx.crypto dependency floor from 0.2.1.1 to the released + 0.2.2, which contains the SAS APIs already required at runtime. CI pins + the mx.crypto 0.2.2 source; no mx.crypto code changes are needed. +* Limit the Unix file-permission assertion to Unix hosts, keeping the + cross-signing cryptography tests active on Windows. + # mx.client 0.2.0.10 * Add standard interactive Matrix SAS verification, including emoji/decimal diff --git a/R/cross-signing.R b/R/cross-signing.R index bcb03eb..b7f9d87 100644 --- a/R/cross-signing.R +++ b/R/cross-signing.R @@ -52,9 +52,9 @@ mx_crypto_cross_signing_load <- function(store_dir) { blob <- jsonlite::fromJSON(paste(readLines(path, warn = FALSE), collapse = "\n"), simplifyVector = FALSE) + crypto_store_version(blob, path) needed <- c("master", "self_signing", "user_signing") - if (!identical(as.integer(blob$version), 1L) || - any(vapply(needed, function(nm) is.null(blob[[nm]]), logical(1)))) { + if (any(vapply(needed, function(nm) is.null(blob[[nm]]), logical(1)))) { stop("mx.client: invalid cross-signing store at ", path, "; refusing to replace an identity whose private keys may be lost", call. = FALSE) diff --git a/R/crypto.R b/R/crypto.R index 36b02b0..12fe536 100644 --- a/R/crypto.R +++ b/R/crypto.R @@ -70,6 +70,9 @@ mx_crypto_random_bytes <- function(n) { #' Unpickles \code{account.pickle} from the store, or mints a fresh #' account and persists it. The account holds the device's long-lived #' Curve25519/Ed25519 identity keys. +#' Reads raw pickles and version-1 JSON envelopes; unknown envelope versions +#' are rejected. Loading never rewrites the file, and saving continues to +#' write raw encrypted pickles for compatibility with older clients. #' #' @param store_dir Character. Crypto store directory. #' @return An mx.crypto account handle. @@ -85,10 +88,10 @@ mx_crypto_random_bytes <- function(n) { mx_crypto_account <- function(store_dir) { mx_require_crypto() dir.create(store_dir, showWarnings = FALSE, recursive = TRUE) - key <- mx_crypto_key(store_dir) pfile <- file.path(store_dir, "account.pickle") if (file.exists(pfile)) { - pickle <- paste(readLines(pfile, warn = FALSE), collapse = "") + pickle <- crypto_account_pickle(pfile) + key <- mx_crypto_key(store_dir) return(mx.crypto::mxc_account_unpickle(pickle, key)) } acct <- mx.crypto::mxc_account_new() @@ -98,6 +101,9 @@ mx_crypto_account <- function(store_dir) { #' Persist an Olm account to the store #' +#' Writes the raw encrypted account pickle, preserving compatibility with +#' older clients. The encryption key and device identity are unchanged. +#' #' @param account An mx.crypto account handle. #' @param store_dir Character. Crypto store directory. #' @return The pickle path, invisibly. diff --git a/R/e2ee.R b/R/e2ee.R index bd0449c..cd2b7fc 100644 --- a/R/e2ee.R +++ b/R/e2ee.R @@ -38,6 +38,7 @@ mx_crypto_sessions_new <- function() { #' #' Pickles every live session (encrypted at rest with the store key) into #' \code{sessions.json}. Reload with \code{mx_crypto_sessions_load()}. +#' New files declare schema version 1; unversioned legacy files remain readable. #' #' @param sessions A session set. #' @param store_dir Character. Crypto store directory. @@ -55,6 +56,7 @@ mx_crypto_sessions_save <- function(sessions, store_dir) { dir.create(store_dir, showWarnings = FALSE, recursive = TRUE) key <- mx_crypto_key(store_dir) blob <- list( + version = 1L, olm = lapply(sessions$olm, function(s) { mx.crypto::mxc_olm_session_pickle(s, key) }), @@ -81,6 +83,9 @@ mx_crypto_sessions_save <- function(sessions, store_dir) { #' Load a session set from the crypto store #' +#' Accepts version 1 and unversioned legacy stores. Unknown or malformed +#' schema versions are rejected before unpickling, without rewriting the file. +#' #' @param store_dir Character. Crypto store directory. #' @return A session set (empty if nothing is stored yet). #' @examples @@ -96,10 +101,11 @@ mx_crypto_sessions_load <- function(store_dir) { if (!file.exists(path)) { return(mx_crypto_sessions_new()) } - key <- mx_crypto_key(store_dir) blob <- jsonlite::fromJSON(paste(readLines(path, warn = FALSE), collapse = "\n"), simplifyVector = FALSE) + crypto_store_version(blob, path, legacy = TRUE) + key <- mx_crypto_key(store_dir) out <- mx_crypto_sessions_new() for (nm in names(blob$olm %||% list())) { out$olm[[nm]] <- mx.crypto::mxc_olm_session_unpickle(blob$olm[[nm]], key) diff --git a/R/store-version.R b/R/store-version.R new file mode 100644 index 0000000..cc207f4 --- /dev/null +++ b/R/store-version.R @@ -0,0 +1,32 @@ +# The version describes mx.client's file envelope, not the encrypted +# vodozemac payload. Only sessions.json had an unversioned JSON format. +crypto_store_version <- function(blob, path, legacy = FALSE) { + if (is.list(blob) && legacy && !"version" %in% names(blob)) { + return(invisible(0L)) + } + version <- if (is.list(blob)) blob[["version"]] else NULL + if (!is.list(blob) || sum(names(blob) == "version") != 1L || + !(identical(version, 1L) || identical(version, 1))) { + stop("mx.client: unsupported or invalid schema version in ", + path, "; expected version 1. No store was replaced.", + call. = FALSE) + } + invisible(1L) +} + +# Read raw account pickles and versioned envelopes without rewriting them. +# Account saves stay raw until older clients no longer need to read the store. +crypto_account_pickle <- function(path) { + pickle <- trimws(paste(readLines(path, warn = FALSE), collapse = "")) + if (startsWith(pickle, "{")) { + blob <- jsonlite::fromJSON(pickle, simplifyVector = FALSE) + crypto_store_version(blob, path) + pickle <- blob[["pickle"]] + } + if (!is.character(pickle) || length(pickle) != 1L || + is.na(pickle) || !nzchar(pickle)) { + stop("mx.client: invalid ", path, "; expected an encrypted pickle. ", + "No identity was replaced.", call. = FALSE) + } + pickle +} diff --git a/inst/tinytest/test_cross_signing.R b/inst/tinytest/test_cross_signing.R index abcc258..21935e1 100644 --- a/inst/tinytest/test_cross_signing.R +++ b/inst/tinytest/test_cross_signing.R @@ -20,8 +20,12 @@ mx.client:::mx_crypto_cross_signing_save(keys, store) loaded <- mx_crypto_cross_signing_load(store) expect_identical(mx.crypto::mxc_signing_key_public(loaded$master), mx.crypto::mxc_signing_key_public(keys$master)) -expect_identical(as.octmode(file.info(file.path( - store, "cross-signing.json"))$mode), as.octmode("600")) +# Windows file modes do not represent Unix owner/group/other permissions. +# Keep the exact owner-only check on Unix; all key tests run on both platforms. +if (.Platform$OS.type == "unix") { + expect_identical(as.octmode(file.info(file.path( + store, "cross-signing.json"))$mode), as.octmode("600")) +} objects <- mx.client:::mx_crypto_cross_signing_objects( loaded, UID, device, DEV) diff --git a/inst/tinytest/test_store_version.R b/inst/tinytest/test_store_version.R new file mode 100644 index 0000000..3fda241 --- /dev/null +++ b/inst/tinytest/test_store_version.R @@ -0,0 +1,155 @@ +library(tinytest) +library(mx.client) + +if (!requireNamespace("mx.crypto", quietly = TRUE) || + utils::packageVersion("mx.crypto") < "0.2.2") { + exit_file("mx.crypto >= 0.2.2 required") +} + +local({ + store <- tempfile("mx-schema-") + dir.create(store) + on.exit(unlink(store, recursive = TRUE), add = TRUE) + read_blob <- function(path) { + jsonlite::fromJSON(paste(readLines(path), collapse = "\n"), + simplifyVector = FALSE) + } + write_blob <- function(blob, path) { + writeLines(jsonlite::toJSON(blob, auto_unbox = TRUE, null = "null"), path) + } + account <- mx_crypto_account(store) + identity <- mx.crypto::mxc_account_identity_keys(account) + account_path <- file.path(store, "account.pickle") + key <- mx.client:::mx_crypto_key(store) + read_raw <- function() { + pickle <- paste(readLines(account_path), collapse = "") + expect_false(startsWith(trimws(pickle), "{")) + # Same decode path used by older mx.client versions. + mx.crypto::mxc_account_unpickle(pickle, key) + } + expect_identical(mx.crypto::mxc_account_identity_keys(read_raw()), identity) + expect_identical(mx.crypto::mxc_account_identity_keys( + mx_crypto_account(store)), identity) + + # The API returns a named map; ordering is not part of the key identity. + read_otks <- function(account) { + keys <- mx.crypto::mxc_account_one_time_keys(account) + keys[sort(names(keys))] + } + # One-time-key replenishment must not change the account's file format. + mx.crypto::mxc_account_generate_one_time_keys(account, 2L) + one_time_keys <- read_otks(account) + expect_identical(length(one_time_keys), 2L) + mx_crypto_account_save(account, store) + loaded <- read_raw() + expect_identical(mx.crypto::mxc_account_identity_keys(loaded), identity) + expect_identical(read_otks(loaded), one_time_keys) + + # Raw base64 remains raw on save, and loading never rewrites it. + legacy <- mx.crypto::mxc_account_pickle(account, key) + writeLines(legacy, account_path) + before <- tools::md5sum(account_path) + loaded <- mx_crypto_account(store) + expect_identical(mx.crypto::mxc_account_identity_keys(loaded), identity) + expect_identical(tools::md5sum(account_path), before) + mx_crypto_account_save(loaded, store) + expect_identical(mx.crypto::mxc_account_identity_keys(read_raw()), identity) + + # Explicit envelopes still load without rewriting. A later save writes raw. + account_blob <- list(version = 1L, pickle = legacy) + write_blob(account_blob, account_path) + before <- tools::md5sum(account_path) + loaded <- mx_crypto_account(store) + expect_identical(mx.crypto::mxc_account_identity_keys(loaded), identity) + expect_identical(tools::md5sum(account_path), before) + mx_crypto_account_save(loaded, store) + loaded <- read_raw() + expect_identical(mx.crypto::mxc_account_identity_keys(loaded), identity) + expect_identical(read_otks(loaded), one_time_keys) + write_blob(account_blob, account_path) + + # A real Megolm session and pending request survive both formats. + sessions <- mx_crypto_sessions_new() + group <- mx.crypto::mxc_megolm_outbound_new() + info <- mx.crypto::mxc_megolm_outbound_info(group) + sessions$megolm_out[["!room:example.org"]] <- list( + session = group, shared = character()) + sessions$key_requests$fixture <- list(request_id = "fixture", sent = FALSE) + mx_crypto_sessions_save(sessions, store) + sessions_path <- file.path(store, "sessions.json") + sessions_blob <- read_blob(sessions_path) + expect_identical(sessions_blob$version, 1L) + for (versioned in c(TRUE, FALSE)) { + blob <- sessions_blob + if (!versioned) blob$version <- NULL + write_blob(blob, sessions_path) + before <- tools::md5sum(sessions_path) + loaded <- mx_crypto_sessions_load(store) + expect_identical(mx.crypto::mxc_megolm_outbound_info( + loaded$megolm_out[["!room:example.org"]]$session)$session_id, + info$session_id) + expect_identical(loaded$key_requests, sessions$key_requests) + expect_identical(tools::md5sum(sessions_path), before) + } + # Old four-map stores predate key requests. + sessions_blob$version <- NULL + sessions_blob$key_requests <- NULL + write_blob(sessions_blob, sessions_path) + expect_identical(mx_crypto_sessions_load(store)$key_requests, list()) + mx_crypto_sessions_save(mx_crypto_sessions_load(store), store) + expect_identical(read_blob(sessions_path)$version, 1L) + + signing <- mx.client:::mx_crypto_cross_signing_new() + mx.client:::mx_crypto_cross_signing_save(signing, store) + signing_path <- file.path(store, "cross-signing.json") + signing_blob <- read_blob(signing_path) + expect_identical(signing_blob$version, 1L) + expect_identical(mx.crypto::mxc_signing_key_public( + mx_crypto_cross_signing_load(store)$master), + mx.crypto::mxc_signing_key_public(signing$master)) + + # Never coerce malformed versions (1.5, TRUE, "1", [1]) into version 1. + paths <- c(account_path, sessions_path, signing_path) + loaders <- list(mx_crypto_account, mx_crypto_sessions_load, + mx_crypto_cross_signing_load) + blobs <- lapply(paths, read_blob) + for (i in seq_along(paths)) { + for (version in list(2L, 0L, -1L, 1.5, TRUE, "1", list(1L), NULL)) { + blob <- blobs[[i]] + blob["version"] <- list(version) + write_blob(blob, paths[[i]]) + before <- tools::md5sum(paths) + expect_error(loaders[[i]](store), "schema version") + error <- tryCatch(loaders[[i]](store), error = function(e) e) + expect_true(grepl(paths[[i]], conditionMessage(error), + fixed = TRUE)) + expect_identical(tools::md5sum(paths), before) + } + write_blob(blobs[[i]], paths[[i]]) + } + + # Cross-signing and JSON account envelopes never had a versionless form. + for (i in c(1L, 3L)) { + blob <- blobs[[i]] + blob$version <- NULL + write_blob(blob, paths[[i]]) + expect_error(loaders[[i]](store), "schema version") + write_blob(blobs[[i]], paths[[i]]) + } + malformed <- list(version = 1L, pickle = list()) + write_blob(malformed, account_path) + expect_error(mx_crypto_account(store), "expected an encrypted pickle") + error <- tryCatch(mx_crypto_account(store), error = function(e) e) + expect_true(grepl(account_path, conditionMessage(error), fixed = TRUE)) + + # Unsupported versions are checked before any key file is created. + missing_key <- file.path(store, "without-key") + dir.create(missing_key) + for (i in seq_along(paths)) { + blob <- blobs[[i]] + blob$version <- 2L + write_blob(blob, file.path(missing_key, basename(paths[[i]]))) + expect_error(loaders[[i]](missing_key), "schema version") + expect_false(file.exists(file.path(missing_key, "pickle.key"))) + } +}) diff --git a/man/mx_crypto_account.Rd b/man/mx_crypto_account.Rd index 495c204..7d19526 100644 --- a/man/mx_crypto_account.Rd +++ b/man/mx_crypto_account.Rd @@ -15,6 +15,9 @@ An mx.crypto account handle. Unpickles \code{account.pickle} from the store, or mints a fresh account and persists it. The account holds the device's long-lived Curve25519/Ed25519 identity keys. +Reads raw pickles and version-1 JSON envelopes; unknown envelope versions +are rejected. Loading never rewrites the file, and saving continues to +write raw encrypted pickles for compatibility with older clients. } \examples{ \donttest{ diff --git a/man/mx_crypto_account_save.Rd b/man/mx_crypto_account_save.Rd index 035ecfb..659e35b 100644 --- a/man/mx_crypto_account_save.Rd +++ b/man/mx_crypto_account_save.Rd @@ -14,7 +14,8 @@ mx_crypto_account_save(account, store_dir) The pickle path, invisibly. } \description{ -Persist an Olm account to the store +Writes the raw encrypted account pickle, preserving compatibility with +older clients. The encryption key and device identity are unchanged. } \examples{ \donttest{ diff --git a/man/mx_crypto_sessions_load.Rd b/man/mx_crypto_sessions_load.Rd index 90a28d4..656f87b 100644 --- a/man/mx_crypto_sessions_load.Rd +++ b/man/mx_crypto_sessions_load.Rd @@ -12,7 +12,8 @@ mx_crypto_sessions_load(store_dir) A session set (empty if nothing is stored yet). } \description{ -Load a session set from the crypto store +Accepts version 1 and unversioned legacy stores. Unknown or malformed +schema versions are rejected before unpickling, without rewriting the file. } \examples{ \donttest{ diff --git a/man/mx_crypto_sessions_save.Rd b/man/mx_crypto_sessions_save.Rd index 4723388..0787a02 100644 --- a/man/mx_crypto_sessions_save.Rd +++ b/man/mx_crypto_sessions_save.Rd @@ -16,6 +16,7 @@ The path written, invisibly. \description{ Pickles every live session (encrypted at rest with the store key) into \code{sessions.json}. Reload with \code{mx_crypto_sessions_load()}. +New files declare schema version 1; unversioned legacy files remain readable. } \examples{ \donttest{ diff --git a/vignettes/e2ee.md b/vignettes/e2ee.md index 147bc36..f8f3bb9 100644 --- a/vignettes/e2ee.md +++ b/vignettes/e2ee.md @@ -37,7 +37,7 @@ 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. -- **Interactive SAS verification.** With mx.crypto >= 0.2.1.2, an explicit +- **Interactive SAS verification.** With mx.crypto >= 0.2.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. @@ -121,9 +121,9 @@ 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`, +Use mx.client >= 0.2.0.10 with the released mx.crypto >= 0.2.2. Builds without +the SAS primitives 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. @@ -441,6 +441,18 @@ Everything stateful lives in the crypto store directory: | `sessions.json` | pickled Olm/Megolm sessions and outstanding key requests | | `cross-signing.json` | encrypted master, self-signing, and user-signing private keys | +From mx.client 0.2.0.11, `sessions.json` declares schema version 1; +cross-signing files already require it. Unknown or malformed versions are +rejected before unpickling, with the full file path in the error. This is +the mx.client file format version, not the mx.crypto package version. +Unversioned session stores remain readable and migrate on save, never on load. + +`account.pickle` continues to be written as a raw encrypted pickle so older +clients can still read it. The account loader also accepts version-1 JSON +envelopes and refuses unknown versions without rewriting the file. Envelope +writes are deferred until consumers have upgraded; reading one and later +saving the account writes the compatible raw form. + `mx_crypto_sessions_save()` / `mx_crypto_sessions_load()` round-trip the session set, so an established room key keeps decrypting across process restarts. One caution: the account binds to the config's `device_id`.