From 973cc8ca265687f23064230f5f75e45d20bba9bb Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Fri, 11 Sep 2026 06:36:15 -0500 Subject: [PATCH 1/6] Limit Unix permission assertion to Unix hosts --- inst/tinytest/test_cross_signing.R | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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) From 5edd33143dfd44846db99f3997472ba1de82cb9a Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Fri, 11 Sep 2026 06:36:18 -0500 Subject: [PATCH 2/6] Version crypto stores and align the SAS dependency floor --- .github/workflows/ci.yaml | 10 +-- DESCRIPTION | 2 +- R/cross-signing.R | 4 +- R/crypto.R | 15 +++- R/e2ee.R | 8 +- R/store-version.R | 32 ++++++++ inst/tinytest/test_store_version.R | 120 +++++++++++++++++++++++++++++ man/mx_crypto_account.Rd | 4 + man/mx_crypto_account_save.Rd | 3 +- man/mx_crypto_sessions_load.Rd | 3 +- man/mx_crypto_sessions_save.Rd | 1 + vignettes/e2ee.md | 11 +++ 12 files changed, 199 insertions(+), 14 deletions(-) create mode 100644 R/store-version.R create mode 100644 inst/tinytest/test_store_version.R diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 563990b..92e984f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,14 +24,14 @@ 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. + # 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 diff --git a/DESCRIPTION b/DESCRIPTION index d1c5e24..1622e67 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -27,7 +27,7 @@ Imports: tools, utils Suggests: - mx.crypto (>= 0.2.1.1), + mx.crypto (>= 0.2.1.2), simplermarkdown, tinytest VignetteBuilder: simplermarkdown 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..390d527 100644 --- a/R/crypto.R +++ b/R/crypto.R @@ -70,6 +70,10 @@ 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. +#' Legacy raw pickles remain readable. New saves use a version-1 JSON +#' envelope around the encrypted pickle; unknown versions are rejected. +#' Loading does not migrate the file. Clients older than 0.2.0.11 cannot +#' read the new envelope, so back up the store before upgrading or downgrading. #' #' @param store_dir Character. Crypto store directory. #' @return An mx.crypto account handle. @@ -85,10 +89,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 +102,9 @@ mx_crypto_account <- function(store_dir) { #' Persist an Olm account to the store #' +#' Writes a version-1 JSON envelope containing the encrypted account pickle. +#' 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. @@ -116,7 +123,9 @@ mx_crypto_account_save <- function(account, store_dir) { dir.create(store_dir, showWarnings = FALSE, recursive = TRUE) key <- mx_crypto_key(store_dir) pfile <- file.path(store_dir, "account.pickle") - writeLines(mx.crypto::mxc_account_pickle(account, key), pfile) + blob <- list(version = 1L, + pickle = mx.crypto::mxc_account_pickle(account, key)) + writeLines(jsonlite::toJSON(blob, auto_unbox = TRUE), pfile) Sys.chmod(pfile, mode = "0600") invisible(pfile) } 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..71cbbfe --- /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 ", + basename(path), "; expected version 1. No store was replaced.", + call. = FALSE) + } + invisible(1L) +} + +# Read legacy raw account pickles without rewriting them. The next explicit +# account save writes the versioned envelope around the same encrypted data. +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 account.pickle; expected an encrypted pickle. ", + "No identity was replaced.", call. = FALSE) + } + pickle +} diff --git a/inst/tinytest/test_store_version.R b/inst/tinytest/test_store_version.R new file mode 100644 index 0000000..77ef205 --- /dev/null +++ b/inst/tinytest/test_store_version.R @@ -0,0 +1,120 @@ +library(tinytest) +library(mx.client) + +if (!requireNamespace("mx.crypto", quietly = TRUE) || + utils::packageVersion("mx.crypto") < "0.2.1.2") { + exit_file("mx.crypto >= 0.2.1.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") + account_blob <- read_blob(account_path) + expect_identical(account_blob$version, 1L) + expect_identical(mx.crypto::mxc_account_identity_keys( + mx_crypto_account(store)), identity) + + # Legacy base64 is read as-is and becomes versioned only on save. + key <- mx.client:::mx_crypto_key(store) + 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(read_blob(account_path)$version, 1L) + expect_identical(mx.crypto::mxc_account_identity_keys( + mx_crypto_account(store)), identity) + + # 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") + 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") + + # 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..fd9f101 100644 --- a/man/mx_crypto_account.Rd +++ b/man/mx_crypto_account.Rd @@ -15,6 +15,10 @@ 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. +Legacy raw pickles remain readable. New saves use a version-1 JSON +envelope around the encrypted pickle; unknown versions are rejected. +Loading does not migrate the file. Clients older than 0.2.0.11 cannot +read the new envelope, so back up the store before upgrading or downgrading. } \examples{ \donttest{ diff --git a/man/mx_crypto_account_save.Rd b/man/mx_crypto_account_save.Rd index 035ecfb..d2df6d6 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 a version-1 JSON envelope containing the encrypted account pickle. +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..86d7c23 100644 --- a/vignettes/e2ee.md +++ b/vignettes/e2ee.md @@ -441,6 +441,17 @@ 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, all 3 store files declare schema version 1. +Unknown or malformed versions are rejected before unpickling. This is the +mx.client file format version, not the mx.crypto package version. +Unversioned `sessions.json` and raw base64 `account.pickle` files remain +readable and are migrated on the next save, never by a read-only load. +Cross-signing files have always required an explicit version. + +New `account.pickle` files use a JSON envelope around the encrypted pickle. +Clients older than 0.2.0.11 cannot read that envelope. Back up the whole store +before upgrading; preserve that backup if an older client may need to resume. + `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`. From c2e48f6243e6f6a69849ac69f70e504dd2a21776 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Fri, 11 Sep 2026 06:37:07 -0500 Subject: [PATCH 3/6] Bump version to 0.2.0.11 --- DESCRIPTION | 4 ++-- NEWS.md | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 1622e67..76cd921 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", diff --git a/NEWS.md b/NEWS.md index 4a438d9..1bcf3ee 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,16 @@ +# mx.client 0.2.0.11 + +* Write schema version 1 in session stores and account-pickle JSON envelopes; + reject unknown or malformed versions in all 3 crypto-store loaders. + Legacy unversioned sessions and raw account pickles remain readable and + migrate only on save. Older clients cannot read the new account envelope; + retain a pre-upgrade store backup for rollback. +* Raise the optional mx.crypto dependency floor from 0.2.1.1 to 0.2.1.2, + matching the SAS APIs already required at runtime. CI pins the released + 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 From 6d2ef8790a1a51b97859d3e27bcdf633198ce7a0 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Fri, 11 Sep 2026 06:44:50 -0500 Subject: [PATCH 4/6] Install pinned Matrix sources after generic CI dependencies --- .github/install-matrix-deps.R | 3 ++- .github/workflows/ci.yaml | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/install-matrix-deps.R b/.github/install-matrix-deps.R index 8546e64..add560f 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) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 92e984f..7c0eb83 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,6 +24,10 @@ jobs: with: backend: RAPT + - 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 @@ -36,9 +40,6 @@ jobs: 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. From 6ba54cea88c274a85796d84763e92e2913f110de Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Fri, 11 Sep 2026 07:04:48 -0500 Subject: [PATCH 5/6] Preserve raw account pickles for older clients --- DESCRIPTION | 2 +- NEWS.md | 17 +++++----- R/crypto.R | 15 ++++----- R/store-version.R | 8 ++--- inst/tinytest/test_store_version.R | 53 +++++++++++++++++++++++++----- man/mx_crypto_account.Rd | 7 ++-- man/mx_crypto_account_save.Rd | 4 +-- vignettes/e2ee.md | 29 ++++++++-------- 8 files changed, 84 insertions(+), 51 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 76cd921..a84d64e 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -27,7 +27,7 @@ Imports: tools, utils Suggests: - mx.crypto (>= 0.2.1.2), + mx.crypto (>= 0.2.2), simplermarkdown, tinytest VignetteBuilder: simplermarkdown diff --git a/NEWS.md b/NEWS.md index 1bcf3ee..64cd7e8 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,13 +1,14 @@ # mx.client 0.2.0.11 -* Write schema version 1 in session stores and account-pickle JSON envelopes; - reject unknown or malformed versions in all 3 crypto-store loaders. - Legacy unversioned sessions and raw account pickles remain readable and - migrate only on save. Older clients cannot read the new account envelope; - retain a pre-upgrade store backup for rollback. -* Raise the optional mx.crypto dependency floor from 0.2.1.1 to 0.2.1.2, - matching the SAS APIs already required at runtime. CI pins the released - mx.crypto 0.2.2 source; no mx.crypto code changes are needed. +* 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. diff --git a/R/crypto.R b/R/crypto.R index 390d527..12fe536 100644 --- a/R/crypto.R +++ b/R/crypto.R @@ -70,10 +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. -#' Legacy raw pickles remain readable. New saves use a version-1 JSON -#' envelope around the encrypted pickle; unknown versions are rejected. -#' Loading does not migrate the file. Clients older than 0.2.0.11 cannot -#' read the new envelope, so back up the store before upgrading or downgrading. +#' 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. @@ -102,8 +101,8 @@ mx_crypto_account <- function(store_dir) { #' Persist an Olm account to the store #' -#' Writes a version-1 JSON envelope containing the encrypted account pickle. -#' The encryption key and device identity are unchanged. +#' 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. @@ -123,9 +122,7 @@ mx_crypto_account_save <- function(account, store_dir) { dir.create(store_dir, showWarnings = FALSE, recursive = TRUE) key <- mx_crypto_key(store_dir) pfile <- file.path(store_dir, "account.pickle") - blob <- list(version = 1L, - pickle = mx.crypto::mxc_account_pickle(account, key)) - writeLines(jsonlite::toJSON(blob, auto_unbox = TRUE), pfile) + writeLines(mx.crypto::mxc_account_pickle(account, key), pfile) Sys.chmod(pfile, mode = "0600") invisible(pfile) } diff --git a/R/store-version.R b/R/store-version.R index 71cbbfe..cc207f4 100644 --- a/R/store-version.R +++ b/R/store-version.R @@ -8,14 +8,14 @@ crypto_store_version <- function(blob, path, legacy = FALSE) { if (!is.list(blob) || sum(names(blob) == "version") != 1L || !(identical(version, 1L) || identical(version, 1))) { stop("mx.client: unsupported or invalid schema version in ", - basename(path), "; expected version 1. No store was replaced.", + path, "; expected version 1. No store was replaced.", call. = FALSE) } invisible(1L) } -# Read legacy raw account pickles without rewriting them. The next explicit -# account save writes the versioned envelope around the same encrypted data. +# 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, "{")) { @@ -25,7 +25,7 @@ crypto_account_pickle <- function(path) { } if (!is.character(pickle) || length(pickle) != 1L || is.na(pickle) || !nzchar(pickle)) { - stop("mx.client: invalid account.pickle; expected an encrypted pickle. ", + stop("mx.client: invalid ", path, "; expected an encrypted pickle. ", "No identity was replaced.", call. = FALSE) } pickle diff --git a/inst/tinytest/test_store_version.R b/inst/tinytest/test_store_version.R index 77ef205..3fda241 100644 --- a/inst/tinytest/test_store_version.R +++ b/inst/tinytest/test_store_version.R @@ -2,8 +2,8 @@ library(tinytest) library(mx.client) if (!requireNamespace("mx.crypto", quietly = TRUE) || - utils::packageVersion("mx.crypto") < "0.2.1.2") { - exit_file("mx.crypto >= 0.2.1.2 required") + utils::packageVersion("mx.crypto") < "0.2.2") { + exit_file("mx.crypto >= 0.2.2 required") } local({ @@ -20,13 +20,32 @@ local({ account <- mx_crypto_account(store) identity <- mx.crypto::mxc_account_identity_keys(account) account_path <- file.path(store, "account.pickle") - account_blob <- read_blob(account_path) - expect_identical(account_blob$version, 1L) + 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) - # Legacy base64 is read as-is and becomes versioned only on save. - key <- mx.client:::mx_crypto_key(store) + # 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) @@ -34,9 +53,20 @@ local({ 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(read_blob(account_path)$version, 1L) - expect_identical(mx.crypto::mxc_account_identity_keys( - mx_crypto_account(store)), identity) + 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() @@ -90,6 +120,9 @@ local({ 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]]) @@ -106,6 +139,8 @@ local({ 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") diff --git a/man/mx_crypto_account.Rd b/man/mx_crypto_account.Rd index fd9f101..7d19526 100644 --- a/man/mx_crypto_account.Rd +++ b/man/mx_crypto_account.Rd @@ -15,10 +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. -Legacy raw pickles remain readable. New saves use a version-1 JSON -envelope around the encrypted pickle; unknown versions are rejected. -Loading does not migrate the file. Clients older than 0.2.0.11 cannot -read the new envelope, so back up the store before upgrading or downgrading. +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 d2df6d6..659e35b 100644 --- a/man/mx_crypto_account_save.Rd +++ b/man/mx_crypto_account_save.Rd @@ -14,8 +14,8 @@ mx_crypto_account_save(account, store_dir) The pickle path, invisibly. } \description{ -Writes a version-1 JSON envelope containing the encrypted account pickle. -The encryption key and device identity are unchanged. +Writes the raw encrypted account pickle, preserving compatibility with +older clients. The encryption key and device identity are unchanged. } \examples{ \donttest{ diff --git a/vignettes/e2ee.md b/vignettes/e2ee.md index 86d7c23..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,16 +441,17 @@ 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, all 3 store files declare schema version 1. -Unknown or malformed versions are rejected before unpickling. This is the -mx.client file format version, not the mx.crypto package version. -Unversioned `sessions.json` and raw base64 `account.pickle` files remain -readable and are migrated on the next save, never by a read-only load. -Cross-signing files have always required an explicit version. - -New `account.pickle` files use a JSON envelope around the encrypted pickle. -Clients older than 0.2.0.11 cannot read that envelope. Back up the whole store -before upgrading; preserve that backup if an older client may need to resume. +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 From 8df029964912d3b80352431057c263c976856217 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Fri, 11 Sep 2026 07:04:49 -0500 Subject: [PATCH 6/6] Require the released mx.crypto floor in CI --- .github/check-results.R | 2 +- .github/install-matrix-deps.R | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 add560f..77b8d7c 100644 --- a/.github/install-matrix-deps.R +++ b/.github/install-matrix-deps.R @@ -26,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"))