janssonr is on CRAN, and mx.* can verify devices now

Posted by Troy Hernandez on Sat, Sep 12, 2026

Four packages went up on CRAN this week. One is new.

PackageVersionPublishedHighlight
janssonr0.1.22026-09-12first release: strict JSON, 2 functions, 0 R dependencies
mx.api0.3.12026-09-11cross-signing upload endpoints, spaces at room creation
mx.crypto0.2.22026-09-11cross-signing keys, forwarded Megolm keys, SAS primitives
mx.client0.2.12026-09-11interactive SAS verification, cross-signing bootstrap, room-key recovery

janssonr

Back in July I got 6 months of Claude Max 20x through the Claude for Open Source program. Thanks Anthropic! With that much compute I felt obliged to try some of my more experimental ideas. Hopefully I’ll be talking about those ideas in the future. Today, though, it’s one of the packages those experiments turned out to need, and one that seemed generally useful for the R community: janssonr.

install.packages("janssonr")

Strict JSON for R, backed by the Jansson C library. Two functions, from_json() and to_json(), no R package dependencies, R 4.4 or newer.

library(janssonr)

from_json('{"a": 1, "b": [true, null]}')
#> $a
#> [1] 1
#> $b
#> $b[[1]]
#> [1] TRUE
#> $b[[2]]
#> NULL

to_json(list(a = 1L, b = list(TRUE, NULL)))
#> [1] "{\"a\":1,\"b\":[true,null]}"

Why another JSON package

When JSON comes from a tool’s machine output, I want an error at the parse with a position when it’s wrong. Most JSON parsers make a sensible guess, because they’re built for reading an API response into a data frame, where a guess is usually what you want. Here’s jsonlite 2.0.0 next to janssonr on 4 inputs:

Inputjsonlitejanssonr
{"a": 1, "a": 2}a list with 2 elements named aerror, code duplicate_key
90071992547409939007199254740992error, code integer_precision
"a\u0000b""a"error, code null_character
toJSON(list(a = NA)){"a":[null]}error, cannot encode NA in JSON

Those are the right defaults for data analysis and the wrong ones for an audit record. janssonr’s parser also refuses trailing content, invalid UTF-8, lone surrogates, reals that overflow a double, and nesting past 1024 containers. The encoder refuses NaN, infinities, named atomic vectors, classed objects, and anything else with no faithful JSON spelling.

Every error is a classed condition that says where it happened:

tryCatch(from_json('{"a": [1, 2,]}'),
         error = function(e) unclass(e)[c("code", "line", "column", "position")])
#> $code
#> [1] "invalid_syntax"
#> $line
#> [1] 1
#> $column
#> [1] 13
#> $position
#> [1] 13

The encoder’s guarantee is that every finite double round-trips to the bit-identical value. Whole-number doubles are written as integers, so 1234567890123456 stays 1234567890123456, and -0 survives as -0.0. The cost is that 0.1 encodes as 0.10000000000000001. Ugly bytes, exact value.

Installing it

On Unix, janssonr links the system Jansson when a usable one is present, meaning 2.11 or newer: Ubuntu 20.04+, Debian bullseye+, RHEL 8+, and Homebrew all qualify. When there isn’t one, and always on Windows, it compiles the bundled Jansson 2.15.1 instead, so install.packages() works on a machine with no Jansson at all. Ubuntu users who’d rather skip the compiler can get a binary deb from the apt repository.

the matrix packages

install.packages(c("mx.api", "mx.client"))
install.packages("mx.crypto")   # needs cargo and rustc >= 1.85

When I wrote up mx.client 0.1.1 in June, I said mx.api and mx.crypto were stable transport, that mx.client was where the work would happen, and that “trust store, key re-requests, and cross-signing are still to come.” That’s what this is.

What a bot couldn’t do before

Encrypting a message to a device and knowing which account that device belongs to are separate problems. Olm and Megolm handle the first. The second is cross-signing: each account holds a master key, signs its own devices with it, and two people confirm each other’s master keys by comparing 7 emoji on 2 screens. That comparison is SAS, the short authentication string handshake, and it’s what makes the lock icon in Element or FluffyChat mean something.

Until this release my bots’ devices showed up as unverified in the client.

Verifying a bot

client <- mx_client_load(path = config_path)
result <- mx_verify_console(client, crypto_store,
    "@you:example.org", "!room:example.org", exclusive = TRUE)

Stop the bot. Run that in an R console on its host, over SSH is fine. Tap Start verification in FluffyChat on your phone, compare the 7 emoji, confirm on both screens, and restart the bot. result$status tells you whether local trust was recorded and whether the peer acknowledged completion.

Side by side: FluffyChat&rsquo;s &ldquo;Please compare the emojis&rdquo; dialog on the left showing Key, Banana, Dog, Light Bulb, Smiley, Key, Rooster, with They Don&rsquo;t Match and They Match buttons. On the right, an R console running mx_verify_console() that has accepted the request and printed the same 7 emoji, their names, and the decimal form, then asks &ldquo;Do all emoji or all numbers match? Type yes:&rdquo;.

Left is FluffyChat on my laptop, right is the R console on cornelius’s host. Same 7 emoji on two screens, and a human typing yes on both.

I did this with my bots this week. Two rules from the vignette are worth repeating: compare the emoji on your own two screens and don’t let an LLM do it. Some things you want to do yourself. This is one of them.

Before the emoji, the bot needs a cross-signing identity. mx_crypto_cross_signing_bootstrap() creates the master, self-signing, and user-signing keys, stores them encrypted alongside the device account, publishes them through the server’s user-interactive auth, and signs the device. Rerunning it skips signatures already on the server and never resets an existing identity.

The rest of the September releases

mx.client 0.2.1 also:

  • Requests missing room keys. A Megolm session the bot never received now produces an m.room_key_request, retried with the same id until it’s sent, and the forwarded key is accepted only from this user’s own cross-signed devices.
  • Receives Olm on sessions the bot opened itself. A peer’s room-key reply over a locally initiated session used to be ignored, which is how you get a bot that can send into a room but can’t read the reply.
  • Fixes 3 encrypted-send bugs. The self-filter matched on device id alone, and device ids are scoped per user, so two bots both named BOT dropped each other from the recipient list. A send with no usable recipients still posted the event. And a /keys/query answer with a failures map was read as a complete answer, so an unreachable homeserver looked like a user with no devices. All 3 now refuse instead of posting a message no recipient can read.
  • Extracts more from a sync: media events with their encrypted-file metadata, reactions, threaded relations, and pending invites with who sent them, since the inviter is the whole question of whether to accept.
  • Sends into threads with mx_send_text(thread = root_event_id).
  • Versions the crypto store on disk, and still reads the unversioned stores from 0.1.1.

mx.crypto 0.2.2 supplies the primitives underneath: durable Ed25519 signing keys for cross-signing, export and import of inbound Megolm sessions for forwarded keys, and the SAS key agreement, emoji derivation, and constant-time MAC check, all through vodozemac. Still no HTTP in it. mx.api 0.3.1 adds the 2 upload endpoints for cross-signing keys and signatures, plus creation_content on mx_room_create(), which is the only way to make a room a space.

The August releases I never wrote up

mx.client 0.2.0 and mx.crypto 0.2.1 went up on 2026-08-05. The mx.client release was a security pass on the June code. Homeserver-supplied device keys and one-time keys are now verified against their Ed25519 signatures before anything is encrypted to them; the June version read them straight out of the response, so a hostile homeserver could have substituted its own key for any device. Olm payloads now carry the sender and recipient fields the spec requires, and a decrypted payload has to name this device as its recipient before it’s acted on. print() on a client config masks the token and password. And mx_set_displayname() came in as a contributed pull request (#11), so a long-running bot can rename itself.

mx.crypto 0.2.1 was a Windows ARM64 build fix from Jeroen Ooms (#3): the configure script now picks the Rust target from the running R rather than from the host triple. Thanks, Jeroen.

Still to come

No server-side key backup yet, so a fresh device recovers history only through key requests to the bot’s other devices. Unanswered key requests are kept until they’re satisfied; expiry is a follow-up. And if a peer’s Olm session gets replaced, the older one is gone.

File issues on mx.client or janssonr. The full E2EE walkthrough, including what to do when FluffyChat asks you to Restore Crypto Identity, is vignette("e2ee", package = "mx.client").