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
4 changes: 2 additions & 2 deletions DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
Package: mx.api
Type: Package
Title: Minimal Matrix Client-Server API
Version: 0.3.0.1
Date: 2026-06-10
Version: 0.3.0.2
Date: 2026-09-04
Authors@R: c(
person("Troy", "Hernandez", role = c("aut", "cre"),
email = "troy@cornball.ai",
Expand Down
2 changes: 2 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ export(mx_get_account_data)
export(mx_get_state)
export(mx_guess_mime)
export(mx_keys_claim)
export(mx_keys_device_signing_upload)
export(mx_keys_query)
export(mx_keys_signatures_upload)
export(mx_keys_upload)
export(mx_login)
export(mx_logout)
Expand Down
7 changes: 7 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
# mx.api 0.3.0.2

* New: `mx_keys_device_signing_upload()` publishes Matrix master,
self-signing, and user-signing keys with explicit UIA passthrough.
* New: `mx_keys_signatures_upload()` publishes signatures over device and
cross-signing key objects.

# mx.api 0.3.0.1

* `mx_room_create()` gains `creation_content`, merged into the
Expand Down
75 changes: 75 additions & 0 deletions R/keys.R
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,78 @@ mx_send_to_device <- function(session, event_type, messages, txn_id = NULL) {
invisible(NULL)
}


#' Upload cross-signing public keys
#'
#' POST \code{/_matrix/client/v3/keys/device_signing/upload}. The key
#' objects must already carry the signatures required by the Matrix
#' cross-signing specification. Homeservers normally require user-interactive
#' authentication (UIA); the initial 401 response contains a session id which
#' the caller supplies in a completed \code{auth} object on retry.
#'
#' @param session An \code{mx_session}.
#' @param master_key A signed Matrix CrossSigningKey object or NULL.
#' @param self_signing_key A CrossSigningKey signed by the master key, or NULL.
#' @param user_signing_key A CrossSigningKey signed by the master key, or NULL.
#' @param auth Completed UIA authentication object or NULL.
#' @return Parsed homeserver response.
#' @examples
#' \dontrun{
#' mx_keys_device_signing_upload(s, master_key = master,
#' self_signing_key = self, user_signing_key = user,
#' auth = list(type = "m.login.password", session = uia_session,
#' identifier = list(type = "m.id.user", user = "bot"),
#' password = "secret"))
#' }
#' @export
mx_keys_device_signing_upload <- function(session, master_key = NULL,
self_signing_key = NULL,
user_signing_key = NULL,
auth = NULL) {
body <- list()
if (!is.null(master_key)) {
body$master_key <- master_key
}
if (!is.null(self_signing_key)) {
body$self_signing_key <- self_signing_key
}
if (!is.null(user_signing_key)) {
body$user_signing_key <- user_signing_key
}
if (!is.null(auth)) {
body$auth <- auth
}
if (!length(body) || (length(body) == 1L && !is.null(body$auth))) {
stop("mx_keys_device_signing_upload: no signing keys supplied",
call. = FALSE)
}
mx_http(session$server, "POST",
"/_matrix/client/v3/keys/device_signing/upload",
body = body, token = session$token)
}

#' Upload signatures over device or cross-signing keys
#'
#' POST \code{/_matrix/client/v3/keys/signatures/upload}. The body maps a
#' user id to key ids and the complete signed key objects. mx.api transports
#' the objects unchanged and performs no signature validation itself.
#'
#' @param session An \code{mx_session}.
#' @param signatures Named list mapping user ids to signed key objects.
#' @return Parsed response, including any per-signature \code{failures}.
#' @examples
#' \dontrun{
#' mx_keys_signatures_upload(s, list(
#' "@bot:example.org" = list(DEVICE = signed_device)))
#' }
#' @export
mx_keys_signatures_upload <- function(session, signatures) {
if (!is.list(signatures) || is.null(names(signatures)) ||
!length(signatures)) {
stop("mx_keys_signatures_upload: 'signatures' must be a non-empty named list",
call. = FALSE)
}
mx_http(session$server, "POST",
"/_matrix/client/v3/keys/signatures/upload",
body = signatures, token = session$token)
}
44 changes: 44 additions & 0 deletions inst/tinytest/test_keys.R
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@ library(tinytest)

# Smoke: functions exist with the expected signatures.
expect_true(is.function(mx.api::mx_keys_upload))
expect_true(is.function(mx.api::mx_keys_device_signing_upload))
expect_true(is.function(mx.api::mx_keys_signatures_upload))
expect_true(is.function(mx.api::mx_keys_query))
expect_true(is.function(mx.api::mx_keys_claim))
expect_true(is.function(mx.api::mx_send_to_device))
expect_equal(names(formals(mx.api::mx_keys_device_signing_upload)),
c("session", "master_key", "self_signing_key", "user_signing_key", "auth"))
expect_equal(names(formals(mx.api::mx_keys_signatures_upload)), c("session", "signatures"))

expect_equal(
names(formals(mx.api::mx_keys_upload)),
Expand All @@ -31,11 +36,50 @@ fake <- mx.api::mx_session(
device_id = "DEV"
)
expect_error(mx.api::mx_keys_upload(fake)) # nothing to upload
expect_error(mx.api::mx_keys_device_signing_upload(fake))
expect_error(mx.api::mx_keys_device_signing_upload(
fake, auth = list(type = "m.login.password")
))
expect_error(mx.api::mx_keys_signatures_upload(fake, list()))
expect_error(mx.api::mx_keys_query(fake, device_keys = "not a list"))
expect_error(mx.api::mx_keys_claim(fake, one_time_keys = list())) # unnamed
expect_error(mx.api::mx_send_to_device(fake, "m.room.encrypted",
messages = list())) # unnamed

# Endpoint bodies are passed through exactly, including UIA on the retry.
local({
ns <- asNamespace("mx.api")
original <- get("mx_http", envir = ns, inherits = FALSE)
calls <- list()
assignInNamespace("mx_http", function(base_url, method, path, body = NULL,
query = NULL, token = NULL) {
calls[[length(calls) + 1L]] <<- list(base_url = base_url, method = method,
path = path, body = body, token = token)
list(failures = list())
}, ns = "mx.api")
on.exit(assignInNamespace("mx_http", original, ns = "mx.api"), add = TRUE)

master <- list(user_id = "@u:example", usage = list("master"),
keys = list("ed25519:master" = "master"))
auth <- list(type = "m.login.password", session = "uia-1",
identifier = list(type = "m.id.user", user = "u"),
password = "secret")
mx_keys_device_signing_upload(fake, master_key = master, auth = auth)
expect_identical(calls[[1]]$method, "POST")
expect_identical(calls[[1]]$path,
"/_matrix/client/v3/keys/device_signing/upload")
expect_identical(calls[[1]]$body$master_key, master)
expect_identical(calls[[1]]$body$auth, auth)
expect_identical(calls[[1]]$token, "tok")

signed <- list("@u:example" = list(DEV = list(device_id = "DEV")))
mx_keys_signatures_upload(fake, signed)
expect_identical(calls[[2]]$method, "POST")
expect_identical(calls[[2]]$path,
"/_matrix/client/v3/keys/signatures/upload")
expect_identical(calls[[2]]$body, signed)
})

# Live round-trip gated on at_home() + env vars. Reuses mx.crypto if
# present so the upload payload is real (signed) — falls back to a
# format-only smoke test otherwise.
Expand Down
43 changes: 43 additions & 0 deletions man/mx_keys_device_signing_upload.Rd
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
% tinyrox says don't edit this manually, but it can't stop you!
\name{mx_keys_device_signing_upload}
\alias{mx_keys_device_signing_upload}
\title{Upload cross-signing public keys}
\usage{
mx_keys_device_signing_upload(
session,
master_key = NULL,
self_signing_key = NULL,
user_signing_key = NULL,
auth = NULL
)
}
\arguments{
\item{session}{An \code{mx_session}.}

\item{master_key}{A signed Matrix CrossSigningKey object or NULL.}

\item{self_signing_key}{A CrossSigningKey signed by the master key, or NULL.}

\item{user_signing_key}{A CrossSigningKey signed by the master key, or NULL.}

\item{auth}{Completed UIA authentication object or NULL.}
}
\value{
Parsed homeserver response.
}
\description{
POST \code{/_matrix/client/v3/keys/device_signing/upload}. The key
objects must already carry the signatures required by the Matrix
cross-signing specification. Homeservers normally require user-interactive
authentication (UIA); the initial 401 response contains a session id which
the caller supplies in a completed \code{auth} object on retry.
}
\examples{
\dontrun{
mx_keys_device_signing_upload(s, master_key = master,
self_signing_key = self, user_signing_key = user,
auth = list(type = "m.login.password", session = uia_session,
identifier = list(type = "m.id.user", user = "bot"),
password = "secret"))
}
}
26 changes: 26 additions & 0 deletions man/mx_keys_signatures_upload.Rd
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
% tinyrox says don't edit this manually, but it can't stop you!
\name{mx_keys_signatures_upload}
\alias{mx_keys_signatures_upload}
\title{Upload signatures over device or cross-signing keys}
\usage{
mx_keys_signatures_upload(session, signatures)
}
\arguments{
\item{session}{An \code{mx_session}.}

\item{signatures}{Named list mapping user ids to signed key objects.}
}
\value{
Parsed response, including any per-signature \code{failures}.
}
\description{
POST \code{/_matrix/client/v3/keys/signatures/upload}. The body maps a
user id to key ids and the complete signed key objects. mx.api transports
the objects unchanged and performs no signature validation itself.
}
\examples{
\dontrun{
mx_keys_signatures_upload(s, list(
"@bot:example.org" = list(DEVICE = signed_device)))
}
}