Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/check-results.R
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
5 changes: 3 additions & 2 deletions .github/install-matrix-deps.R
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"))
17 changes: 9 additions & 8 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
Package: mx.client
Type: Package
Title: Stateful Matrix Client Helpers
Version: 0.2.0.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",
Expand All @@ -27,7 +27,7 @@ Imports:
tools,
utils
Suggests:
mx.crypto (>= 0.2.1.1),
mx.crypto (>= 0.2.2),
simplermarkdown,
tinytest
VignetteBuilder: simplermarkdown
Expand Down
14 changes: 14 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions R/cross-signing.R
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 8 additions & 2 deletions R/crypto.R
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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()
Expand All @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion R/e2ee.R
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
}),
Expand All @@ -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
Expand All @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions R/store-version.R
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 6 additions & 2 deletions inst/tinytest/test_cross_signing.R
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
155 changes: 155 additions & 0 deletions inst/tinytest/test_store_version.R
Original file line number Diff line number Diff line change
@@ -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")))
}
})
3 changes: 3 additions & 0 deletions man/mx_crypto_account.Rd
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
Loading