crowdpower / matrix-e2ee-bin
A static Go binary on mautrix-go that makes a Matrix account an end-to-end-encrypting device (linux x86-64), plus a PHP runner for it.
Package info
crowdpower.app.codey.ch/Packages/matrix-e2ee-bin
pkg:composer/crowdpower/matrix-e2ee-bin
Requires
- php: ^8.3
- symfony/process: ^7.2 || ^8.0
Requires (Dev)
- phpunit/phpunit: ^12
- psr/log: ^3
Suggests
- psr/log: to pass a PSR-3 logger to the Runner
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-10-24 12:46:58 UTC
README
A static Linux x86-64 Go binary on mautrix-go that makes a Matrix account an end-to-end-encrypting device, plus a PHP runner for it. PHP starts the binary once per event, there is no daemon. All state of one bot lives in one SQLite file: Olm account, Olm and Megolm sessions, device lists, trust and the sync position (since).
The account is one of two: an appservice user, where every request uses the appservice as_token and masquerades as the bot user and its device (MSC3202 device_id), so no device access token is ever stored; or an ordinary account of any homeserver (password mode, MATRIX_E2EE_PASSWORD), where login logs the device in with the password, keeps the device's own access token in the store and answers the homeserver's authentication challenges with the password. The identity is carried by three cross-signing seeds that the caller keeps; with them, login restores the same identity on a new device after the store is lost.
It grew out of the spike for site-cpd #845 (Ende-zu-Ende-Verschlüsselung in CPD), which measured the approach against Continuwuity, Element X's strict key sharing, PHP in DDEV and freistilbox. Its consumers are c4c_assistant in site-cpd and chat2note.
Threat model
The homeserver is not trusted with content. A compromised homeserver must not get a room key to a device that no staff member cross-signed, nor get a forged message accepted, and a lost store must not silently trust identities anew. What follows from that is marked in the commands below: send takes the list of intended recipients, decrypt and sync refuse the bot's own session under another sender, pin restores remembered identities without asking the homeserver, and the bot never answers key requests. What the binary cannot prevent: the homeserver sees who talks to whom and when, can drop or delay events, can make the binary exit 5 at will (see "Exit codes"), and sees relations (m.relates_to), which the protocol keeps outside the encryption.
Invocation
matrix-e2ee [global flags] <command> [command flags]
Global flags come before the command, because Go's flag package stops at the first non-flag. Every global flag has an environment variable with the same meaning; the flag wins.
| Flag | Env | Meaning |
|---|---|---|
--hs | MATRIX_E2EE_HS | homeserver URL |
--user | MATRIX_E2EE_USER | bot user id, @assistent:example.org |
MATRIX_E2EE_AS_TOKEN | appservice as_token; env only; exactly one of this and the password | |
MATRIX_E2EE_PASSWORD | the password of an ordinary account (password mode); env only | |
--db | MATRIX_E2EE_DB | path of the SQLite file; created only by login |
MATRIX_E2EE_PICKLE_KEY | key that encrypts the Olm account and sessions in the SQLite, at least 32 bytes; env only | |
--journal-mode | MATRIX_E2EE_JOURNAL_MODE | delete (default, safe on network file systems) or wal |
MATRIX_E2EE_LOG | zerolog level, default warn, clamped to debug .. warn |
Secrets and plaintext never travel in argv, which every user of the host can read in /proc/<pid>/cmdline while the process runs. The as_token, the password and the pickle key are env only; seeds, events and the text of send come on stdin. The one secret on stdout is the device's access token that login prints in password mode, for the caller's own calls to the homeserver.
stdout carries exactly one JSON object and nothing else. On failure it is {"ok": false, "error": "..."} plus the fields named per command. stderr carries the log as JSON lines. It never contains plaintext, private keys or seeds, also not at debug. mautrix-go logs request bodies at debug; the binary drops that field (req_body), and drops any line it cannot parse that mentions it. The level never goes below debug, where mautrix-go would log decrypted payloads, nor above warn, because sync reads the warnings (see sync). The integration run greps stderr at debug level for every plaintext and seed it used.
The whole command runs under one deadline of 50 s plus the long-poll time of sync, so it reports before the PHP runner's 60 s backstop.
Exit codes
| Code | Meaning | Caller's reaction |
|---|---|---|
| 0 | done | read stdout |
| 1 | runtime failure: homeserver unreachable, 5xx or a wrong as_token; a key query that misses a user; the store busy, unreadable, full, written by a newer version, or not an SQLite file; the store lock held for more than 10 s; a failed store write while processing sync; login failed halfway | log, retry later |
| 2 | usage: unknown command or flag, missing setting, pickle key shorter than 32 bytes, malformed stdin, login on a path that exists, send into an unencrypted room | bug in the caller |
| 3 | decrypt: no Megolm session for the event | sync, then decrypt again |
| 4 | decrypt: content withheld (sender device below --min-trust, or the bot's own session under another sender); send with recipients: a joined member outside them | fixed text to the sender; details on stdout |
| 5 | device or store lost: the store file does not exist, holds no device, or holds a device but no since; in password mode holds no access token, or the homeserver answers M_UNKNOWN_TOKEN (the device was logged out or deleted); the pickle key does not fit (bad MAC); the homeserver answers M_FORBIDDEN naming the device; the account's keys disagree with the server's; the homeserver rejects since | move the SQLite aside (keep it), alert, login again from the seeds, pin the remembered identities |
Measured against Continuwuity v26.9.1 (see "Integration run"): a device deleted on the server answers 403 M_FORBIDDEN "Device does not exist for user or appservice cannot masquerade as this device.", which is exit 5. A wrong as_token answers 401 M_UNKNOWN_TOKEN, which is exit 1 and leaves the store intact: in appservice mode that token is the as_token, so a new login would fail the same way. In password mode the same answer is exit 5, because the token is the device's own and a new login gets a new one; a wrong password at login is M_FORBIDDEN without a device named, exit 1. The binary also counts 400 M_FORBIDDEN naming the device as exit 5, which is what Synapse is expected to answer for a missing appservice device; Synapse is untested. since counts as rejected on 400 M_INVALID_PARAM or M_UNKNOWN; Continuwuity never answers that (see sync). A store with a device but no since is what a login leaves when it dies between creating the device and saving since.
A malicious homeserver can produce exit 5 at will, by answering M_FORBIDDEN for the device. The recovery costs every Megolm session and the first-seen keys of everyone the caller has not remembered, so the caller keeps the moved-aside store instead of deleting it and alerts a person on every exit 5.
The binary catches a Go panic and reports it as exit 1 with JSON, because Go exits 2 on a panic, which the caller would read as its own bug.
Commands
identity
No server, no store. Generates the three cross-signing seeds from the OS random source.
{"ok": true, "seeds": {"master": "<base64>", "self_signing": "<base64>", "user_signing": "<base64>"}, "master_key": "<base64 ed25519 public>"}
The seeds object is what login reads on stdin. Seeds are 32 bytes each in unpadded standard base64; login also accepts the URL-safe alphabet that mautrix-go's own export uses.
login (--msc4190, --device-name <text>)
Creates the device and publishes the identity of the seeds on stdin. Steps: a new random device id; m.login.application_service with that id, or with --msc4190 PUT /devices/{id} with the as_token, or in password mode m.login.password with that id, whose access token goes into the store; device keys, 50 one-time keys and a fallback key; import the seeds, publish the cross-signing keys (with the as_token no interactive auth; in password mode the homeserver's challenge is answered with the password), write a fresh secret storage key (SSSS) and the keys into it, sign the own device and master key; delete every other device of the user (in password mode after the challenge again); one /sync without since (the only one ever), process it, then save since. --msc4190 needs an appservice.
{"ok": true, "device_id": "...", "access_token": "..." or null, "master_key": "...", "deleted_devices": ["..."], "kept_devices": ["..."], "since": "..."}
access_token is the device's token in password mode, null for an appservice user. kept_devices names the other devices the homeserver would not let the client API delete: Synapse behind the Matrix Authentication Service (matrix.org, nope.chat) answers 404 M_UNRECOGNIZED, devices are deleted in its account management only; login goes on, and the operator deletes them there. On such a server the first upload of the cross-signing keys and a re-upload of the same keys after a lost store pass without a challenge; keys that differ from the account's (a client set up cross-signing before the binary, or other seeds) get an m.oauth challenge, which only the account management can answer: login fails with exit 1 and names the URL the challenge carries (?action=org.matrix.cross_signing_reset); after the reset there, login passes within the server's window. A homeserver that hands out expiring tokens (refresh_token, expires_in_ms) is refused with exit 1: the binary refreshes nothing, and the device would end at the first expiry. Pick a server whose /login offers m.login.password with lasting tokens.
login only ever creates a store: exit 2 when anything exists at the path, so it never migrates or deletes a file it did not make. When a step after creating the file fails, login removes it, so the next login starts clean. The device display name defaults to matrix-e2ee. login does not print the recovery key of the fresh SSSS. The seeds are the backup; the SSSS only makes clients see secret storage as set up. mautrix-go does not keep the private cross-signing keys in the store, so a backup of the SQLite alone never carries the identity.
sync (--timeout <ms>, --min-trust <state>)
Runs /sync from the stored since with set_presence=offline, processes the response (to-device events, device lists, one-time key counts, key upload when below the threshold, room state and memberships), and then saves the new since. A process killed between the two fetches the same to-device events again, and mautrix-go drops the duplicates; saving first would lose the room keys of that response. mautrix-go only logs a failed store write during processing (an inbound session it could not store, for one); the binary counts warnings and errors whose error text is an SQLite error and then keeps since and exits 1. Errors a remote party can cause at will, such as an undecryptable to-device event, do not count, so they cannot stop sync. --timeout is passed to the homeserver for long polling, default 0.
{"ok": true, "since_old": "...", "since_new": "...", "to_device": {"<type>": <count>},
"device_lists_changed": ["@user:..."], "otk_count": <n>, "timeline": [<decrypted event>...],
"timeline_withheld": [<withheld event>...], "timeline_plain": [<plain event>...],
"timeline_undecrypted": [<undecrypted event>...], "invites": [<invite>...], "limited_rooms": ["!..."]}
The response is the whole intake of a consumer without appservice pushes:
timelineholds everym.room.encryptedtimeline event of the response that could be decrypted, in the shape of adecryptresult withoutok. It includes the bot's own messages; filter bysender. An event below--min-trust, or one encrypted with the bot's own session under another sender (seedecrypt), goes totimeline_withheldwithout content:{"event_id", "room_id", "sender", "trust_state", "reason", "sender_master_key", "sender_master_key_first_seen"},reasonas indecrypt.timeline_undecryptedholds the encrypted timeline events that could not be decrypted, also because the sender's keys could not be fetched, each as{"event_id", "room_id", "sender", "origin_server_ts", "event": {<the m.room.encrypted event with room_id>}}, so the caller can keep the envelope anddecryptit after a later sync brought the room key; they are logged at warn as well. Decryption here never waits for keys and never sends key requests.timeline_plainholds every timeline event of a joined or left room that is notm.room.encrypted, in timeline order:{"event_id", "room_id", "sender", "origin_server_ts", "type", "state_key" or null, "content"}. Membership changes (a staff member leaving, the bot kicked) and messages in unencrypted rooms are here. Events of thestatesection (the state before a gap) are not.invitesholds the pending invitations of the bot, from the stripped state of the invite section, which carries no event ids:{"room_id", "sender", "encrypted"},encryptedwhen the stripped state hasm.room.encryption.limited_roomsnames the rooms whose timeline the homeserver cut, so events in between are missing.
Every decrypted and plain event carries origin_server_ts, so a consumer can order the events of one room across the lists.
Continuwuity never rejects a since. It takes an unparsable one for none and answers with a full initial sync, whose timeline repeats old events. Deduplicate by event_id.
decrypt (--min-trust <state>)
stdin carries one m.room.encrypted event as JSON with event_id, room_id and sender, in any valid encoding. mautrix-go keeps the Megolm ciphertext as the raw bytes between the JSON quotes, and PHP's json_encode writes / as \/, so such a ciphertext fails as a corrupted payload. The binary therefore decodes stdin into a generic value and encodes it again with Go before mautrix-go parses it. Object keys come out sorted, numbers unchanged. Exit 2 when stdin is not such an event.
{"ok": true, "event_id": "$...", "room_id": "!...", "sender": "@...", "type": "m.room.message",
"content": {...decrypted content...}, "envelope_relates_to": {...} or null,
"trust_state": "cross-signed-tofu", "sender_device_id": "..." or null,
"sender_master_key": "<base64>" or null, "sender_master_key_first_seen": "<base64>" or null}
trust_state is mautrix-go's id.TrustState string of the sender device; from low to high: blacklisted, device-key-mismatch, unverified, unknown-device, forwarded, backup, cross-signed-untrusted, cross-signed-tofu, cross-signed-verified, verified. --min-trust compares against it; without it there is no threshold. Content withheld is exit 4 without plaintext:
{"ok": false, "error": "...", "reason": "below_min_trust" or "own_session_replay", "trust_state": "...", "min_trust": "...",
"sender": "...", "sender_master_key": ..., "sender_master_key_first_seen": ...}
own_session_replay: mautrix-go marks an event decrypted with the bot's own outbound session as verified and never compares the envelope's sender with the bot, so a homeserver could replay a bot message as sent by a staff member. For any sender but the bot, the binary reports a trust state from cross-signed-tofu up only when the store's device behind the session belongs to that sender; the bot's own session under another sender is always exit 4, with or without --min-trust.
When the sender's device is not in the store, decrypt queries the sender's keys once and decrypts again; a failed query is exit 1, not an unknown-device verdict. Exit 3 with {"ok": false, "error": "...", "no_session": true} when the session is missing.
content is passed through Go's JSON decoder and encoder, so invalid UTF-8 from the sender arrives as U+FFFD. envelope_relates_to is m.relates_to of the encrypted envelope, which the spec keeps in the clear; when the encrypted payload has none, mautrix-go copies the envelope's into the content. Relations are therefore not authenticated: the homeserver can change what a message replies to or edits. Telling an encrypted relation from a copied one would need decrypting outside mautrix-go, so the output has no such field. sender_device_id is the store's device behind the session, or null; the device_id of the envelope is whatever the sender or the homeserver wrote there and is not used. The master keys come from the store's cross-signing keys and are null for a sender without cross-signing.
send (--room, --msgtype, --reply-to, --edit-of)
stdin: {"text": "...", "html": "...", "recipients": ["@user:..."]}, one JSON object with a non-empty text, an optional html and optional recipients; anything else is exit 2. Sends one m.room.message (default m.notice) encrypted into the room.
Before encrypting, send reads m.room.encryption of the room when the store lacks it (exit 2 when the room has none) and fetches the joined members (/joined_members). With recipients, any joined member other than the bot outside the list stops the command before anything is encrypted or sent: exit 4 with {"ok": false, "error": "unexpected_members", "unexpected": ["@..."]}. The homeserver alone decides who is joined, so without the list a member it slipped in, with self-signed keys, would get the room key on first use; consumers pass the list. Then send fetches the members' device lists and cross-signing keys (/keys/query); a member missing from the answer (a remote server down) is exit 1, because the stored list may be stale. It shares the Megolm session with the joined members' devices that are cross-signed by an owner whose master key equals the first-seen one (SendKeysMinTrust = CrossSignedTOFU); every other device gets m.room_key.withheld. Invited members get nothing.
The session rotates after the room's rotation_period_ms / rotation_period_msgs (defaults one week, 100 messages), when the joined members differ from those it was shared for (someone left, or someone joined: mautrix-go would not share an already shared session with them), when a member's device list changed, and when a member's master key differs from the first-seen one while one of their devices holds the session. mautrix-go withholds new sessions from such a user, but without the rotation it would keep encrypting with the session shared before the change.
--reply-to sets m.in_reply_to, --edit-of sends an m.replace with m.new_content; the two exclude each other. html adds formatted_body.
{"ok": true, "event_id": "$...", "session_id": "...", "rotated": false,
"recipients": [{"user_id": "@...", "master_key": "<base64>" or null, "first_seen": "<base64>" or null,
"changed": false, "devices_shared": 2, "devices_withheld": 0}]}
rotated is true when the message went out in a session that did not exist when the command started (also the first message in a room). recipients lists the joined members except the bot. changed is true when the user's current master key differs from the first-seen one. devices_shared counts the user's devices that received the room key of this message's session, by this or an earlier send; a device that got only m.no_olm (no one-time key) does not count. devices_withheld counts the m.room_key.withheld messages this command sent to the user's devices, so it is 0 when the session was shared by an earlier send. Unverified and stale devices of a user count as withheld. A user without cross-signing has master_key null and devices_shared 0. The binary records the members and receiving devices of each session in its own tables in the store, because mautrix-go records neither.
trust (--user-id, --fetch)
What the store knows about one user, with --fetch after a /keys/query (a user missing from the answer is exit 1).
{"ok": true, "user_id": "...", "master_key": ... or null, "first_seen": ... or null, "changed": false,
"devices": [{"device_id": "...", "trust": "<trust state>"}]}
pin (--user-id, --master-key)
Sets the first-seen master key of the user to the given one, nothing else, without asking the homeserver; used after a fresh login to replay the identities the caller remembers. When the store has no row for the user, pin creates one with the given key as current and first-seen key. A later /keys/query writes only the current key, so a different identity then shows as changed and gets no sessions until tofu-reset. A failed query can therefore never leave the user to trust on first use again. mautrix-go's ResetMasterKeyTOFU can only set first-seen to the current key, so pin writes the column itself. stdout: {"ok": true}.
tofu-reset (--user-id, --master-key, --room)
Accepts the user's new master key as first seen (mautrix-go ResetMasterKeyTOFU, after a /keys/query when the store has not seen that key yet; exit 1 when the key is not the user's current one) and drops the outbound Megolm sessions of --room and of every other room the store shares with the user, so the next send there starts a new session that the newly accepted devices get. A session once withheld from a device is never shared with it later. stdout: {"ok": true, "outbound_session_dropped": true}.
Room key requests
mautrix-go answers room key requests from other devices in the background. The binary rejects every one without an answer: a device that did not get a session at sharing time never gets it later.
Store
One SQLite file, opened with modernc.org/sqlite (pure Go, so the binary stays static; cryptohelper's own path handling would need cgo), foreign_keys=1, busy_timeout=5000, _txlock=immediate, journal mode per --journal-mode, one connection. mautrix-go's crypto.SQLCryptoStore and sqlstatestore.SQLStateStore run their migrations on open; the binary adds three tables of its own (matrix_e2ee_session_member, matrix_e2ee_session_device, matrix_e2ee_device). Device id and since live in crypto_account; the pickle key does not live in the store. In password mode matrix_e2ee_device holds the device's access token in the clear: the store holds the Megolm sessions anyway, so the token makes it no more sensitive, and a store without the token (one from appservice mode) is exit 5 in password mode. Every command but login refuses a missing file instead of creating one. Losing the file loses all sessions and the first-seen keys, not the identity (seeds).
Use delete on network file systems: WAL keeps its index in a memory-mapped -shm file that is not coherent across the nodes of a cluster.
Concurrency
The binary must never run twice at once on the same SQLite. SQLite's own locking only protects the file. Two send into one room would both load the outbound session and encrypt with the same Megolm index, and every recipient rejects the second message as a replay (measured in the spike). The caller holds a lock around every start; the PHP runner takes it as a callable. As a second guard the binary takes an exclusive fcntl lock on <db>.lock itself and waits at most 10 s for it (exit 1). fcntl is the lock the spike measured to work across the nodes of freistilbox's MooseFS. The caller's own lock must use another file or another mechanism: on a network file system flock and fcntl locks on the same file can block each other, and the caller would wait for a binary that waits for the caller.
Build and reproducibility
./build.sh # build bin/matrix-e2ee in Docker, write VERSION
./build.sh --check # rebuild into .build-check/, compare with bin/ and VERSION
build.sh runs CGO_ENABLED=0 GOTOOLCHAIN=local go build -trimpath -tags goolm -ldflags='-s -w' -o bin/matrix-e2ee ./cmd/matrix-e2ee in golang:<toolchain>-alpine, with the toolchain pinned by the toolchain line of go.mod (go1.26.10). GOFLAGS adds -mod=readonly, because composer's vendor/ would make Go assume vendoring, and -buildvcs=false, because a VCS stamp would change the binary with every commit. Module and build caches live in .gomod/ and .gocache/ (gitignored). goolm is mautrix-go's pure-Go Olm, so no libolm is needed.
VERSION holds the binary's SHA-256 and the versions of Go, mautrix-go, go.mau.fi/util and modernc.org/sqlite. tests/src/BinaryHashTest.php checks the committed binary against VERSION; ./build.sh --check checks that VERSION and the binary come from the source, byte for byte. The check needs Docker and about a minute with warm caches, so it is not part of the PHPUnit suite; running it in the consumer's CI is a follow-up there. The image is pinned by tag, not by digest; the Go toolchain inside is checked against go.mod. On 2026-10-23 every build was rebuilt with --check (one of them with an empty build cache) and gave the same SHA-256. The binary is 13.0 MB (13,025,442 bytes).
Go tests need no homeserver: go test -tags goolm ./... (in the same image, with GOFLAGS=-mod=mod). A test homeserver (cmd/matrix-e2ee/fakehs_test.go) serves key query and claim, joined members, sending and to-device messages for partners whose devices hold real Olm accounts and are really cross-signed. The tests cover the own-session replay, --min-trust in decrypt and sync, the recipient check, rotation on joins, leaves and identity changes, pin with a differing key, tofu-reset with a wrong key and across rooms, the shared and withheld counts, a key query that misses a member, the exit-code classification, the store lock against another process, the command deadline and the field set of every output shape.
PHP side
Composer package crowdpower/matrix-e2ee-bin, namespace CrowdPower\MatrixE2ee, requires php ^8.3 and symfony/process; psr/log is suggested for the optional logger. The binary is installed as vendor/bin/matrix-e2ee.
use CrowdPower\MatrixE2ee\Connection;
use CrowdPower\MatrixE2ee\Exception\NoSessionException;
use CrowdPower\MatrixE2ee\Exception\StoreUnusableException;
use CrowdPower\MatrixE2ee\Exception\TrustException;
use CrowdPower\MatrixE2ee\Exception\UnexpectedMembersException;
use CrowdPower\MatrixE2ee\JournalMode;
use CrowdPower\MatrixE2ee\Runner;
use CrowdPower\MatrixE2ee\Seeds;
use CrowdPower\MatrixE2ee\SendRequest;
$connection = new Connection(
hs: 'https://matrix.example.org',
user: '@assistent:example.org',
asToken: $asToken, // or asToken: NULL, password: $password for an ordinary account
db: '/var/private/matrix/bot.db',
pickleKey: $pickleKey, // at least 32 bytes
journalMode: JournalMode::Delete,
);
// The lock acquires, runs, releases (also on an exception) and returns what
// the run returned. Not <db>.lock: the binary locks that one itself.
$lock = function (callable $run): mixed {
$handle = fopen('/var/private/matrix/bot.db.caller-lock', 'c');
flock($handle, LOCK_EX);
try {
return $run();
}
finally {
flock($handle, LOCK_UN);
fclose($handle);
}
};
$runner = new Runner($connection, $lock, $logger);
// Once: create the identity, keep the seeds as a secret, log in.
$identity = $runner->identity();
store_secret($identity->seeds->toJson());
$runner->login($identity->seeds);
// Per pushed m.room.encrypted event.
try {
$event = $runner->decrypt($pushBody, 'cross-signed-tofu');
}
catch (NoSessionException) {
$runner->sync();
$event = $runner->decrypt($pushBody, 'cross-signed-tofu');
}
catch (TrustException $e) {
// $e->reason, $e->trustState, $e->sender, $e->senderMasterKey, $e->senderMasterKeyFirstSeen
}
try {
$sent = $runner->send(new SendRequest($event->roomId, 'Received.', replyTo: $event->eventId, recipients: $staffOfThisRoom));
}
catch (UnexpectedMembersException $e) {
// $e->unexpected: joined members nobody added; nothing was sent.
}
foreach ($sent->changedRecipients() as $recipient) {
// Identity changed: confirm, then $runner->tofuReset($recipient->userId, $recipient->masterKey, $roomId).
}
// After a lost store: keep the old one, alert, start over.
catch (StoreUnusableException) {
rename($db, $db . '.broken-' . time());
alert_a_person();
$runner->login(Seeds::fromJson(load_secret()));
foreach ($remembered as $userId => $masterKey) {
$runner->pin($userId, $masterKey);
}
}
Binary::path(): the envMATRIX_E2EE_BINif set (and then it must be executable, no fallback), else the packagedbin/matrix-e2eeon Linux x86-64, elsematrix-e2eeon thePATH; throwsBinaryNotFoundExceptionwhen none is executable. A$binarygiven to theRunnermust be an executable file as well.Binary::packagedVersion()returns the SHA-256 fromVERSION,Binary::packagedComponents()all its lines.Runner:new Runner(Connection $connection, callable $lock, ?LoggerInterface $logger = NULL, ?string $binary = NULL, float $timeout = 60.0). One method per command:identity(),login(Seeds, bool $msc4190 = FALSE, ?string $deviceName = NULL),sync(int $timeoutMs = 0, ?string $minTrust = NULL),decrypt(string $eventJson, string $minTrust),send(SendRequest),trust(string $userId, bool $fetch = FALSE),pin(string $userId, string $masterKey): void,tofuReset(string $userId, string $masterKey, string $roomId). Each returns a readonly result object fromResult\built offensively from stdout: a missing or wrongly typed field throws\UnexpectedValueExceptionnaming the field, the expected and the actual type, as does stdout that is not exactly one JSON object and an exit code outside 0 to 5. Invalid arguments throw\InvalidArgumentExceptionbefore anything runs.SendRequest(string $roomId, string $text, ?string $html = NULL, string $msgtype = 'm.notice', ?string $replyTo = NULL, ?string $editOf = NULL, ?array $recipients = NULL): pass$recipients, seesend.SyncResulthastimeline,withheld(WithheldEventwithreason),undecrypted(UndecryptedEventwith the envelope,eventJson()fordecrypt),plain(PlainEvent),invites(Invite),limitedRoomsandtimelineInvalid: a timeline entry that does not parse lands there as an\UnexpectedValueException, and the rest of the batch is still returned.DecryptResult::$contentis an array holding the JSON object or list the sender wrote;DecryptResult::$originServerTsandPlainEvent::$originServerTsorder the events of a room across the lists.LoginResult::$accessTokenis the device's token in password mode, NULL otherwise.- Every call runs inside the lock callable; a lock that returns without running the command throws
\UnexpectedValueException. Only the process runs inside the lock, the parsing happens after it is released. - The connection reaches the binary as
MATRIX_E2EE_*environment variables; seeds, events and the text ofSendRequestgo on stdin. argv carries only the command and its flags, each as one--flag=valueargument.identitygets none of the connection's variables. The as_token, the password, the pickle key and the seeds are not properties ofConnectionandSeeds:var_dump(),var_export(),print_r(), an(array)cast and stack traces show none of them, andjson_encode()andserialize()throw.Connection::asToken(),Connection::password(),Connection::pickleKey(),Seeds::master(),Seeds::selfSigning(),Seeds::userSigning(),Seeds::toArray()andSeeds::toJson()read them on purpose.Connectiontakes exactly one ofasTokenandpassword(isPasswordMode()). - Exit codes become exceptions, all subclasses of
Exception\MatrixE2eeExceptionwithcommand,exitCode,errorandstderr:BinaryFailedException(1, a signal such as the OOM killer's, or a timeout;exitCodeNULL for the latter two),UsageException(2),NoSessionException(3),TrustException(4 ofdecrypt, withreason,trustState,minTrust,sender,senderMasterKey,senderMasterKeyFirstSeen),UnexpectedMembersException(4 ofsend, withunexpected),StoreUnusableException(5). - The logger gets one record per call: debug for exit 0 and 3, notice for 4, error otherwise, with command, exit code, duration and stderr in the context; a failure carries the binary's error in the message itself (
{error}), because a log backend may keep only the message's placeholders. - The process timeout is 60 s plus the long-poll time of
sync, 10 s more than the binary's own deadline.
Fake binary for consumers' tests
tests/fake/matrix-e2ee is an executable PHP script that plays the binary. MATRIX_E2EE_FAKE_SCRIPT names a JSON file with the list of expected calls, which the fake consumes in order.
[{"command": "decrypt", "stdin_contains": "...", "env": {"MATRIX_E2EE_PICKLE_KEY": "..."}, "exit": 0, "stdout": {...}, "stderr": "..."}]
Only command is required. stdout_raw (a string) replaces stdout for malformed output; env requires these variables with these values. The fake appends every call as one JSON line to MATRIX_E2EE_FAKE_LOG: command, args, argv, the names of the MATRIX_E2EE_* variables (not their values) and stdin; the number of lines is the position in the script. A call that does not match the next expectation, or comes after the last one, exits 99 with the reason on stderr, which the runner turns into an \UnexpectedValueException.
Testing\FakeBinary (autoloaded, so consumers' tests can use it) writes the script, gives an executable wrapper that runs the fake with PHP_BINARY, and reads the log back; expectNext() inserts a call at the current position, before the pending ones, for a helper that knows the calls its own run makes first. Testing\Stdout returns the stdout shapes of the real binary with plausible values and named arguments to change them, including ownSessionReplay(), unexpectedMembers() and withheld().
use CrowdPower\MatrixE2ee\Testing\FakeBinary;
use CrowdPower\MatrixE2ee\Testing\Stdout;
$fake = (new FakeBinary())
->expect('decrypt', Stdout::noSession(), exit: 3)
->expect('sync', Stdout::sync())
->expect('decrypt', Stdout::decrypt(Stdout::decrypted(body: 'Hello')), stdinContains: '"m.room.encrypted"')
->install();
// ... code under test runs the Runner ...
$this->assertSame([], $fake->remaining());
$this->assertSame('decrypt', $fake->calls()[0]['command']);
$fake->cleanup();
install() sets MATRIX_E2EE_BIN, MATRIX_E2EE_FAKE_SCRIPT and MATRIX_E2EE_FAKE_LOG in getenv() and $_ENV of the test process (Symfony Process passes on only variables present in $_ENV or $_SERVER). When the code under test runs in another process, such as a web server, hand it env() instead or construct the Runner with $fake->path().
The package's own tests run the runner against the fake for every command and every exit code, and against the packaged binary for what needs no homeserver. PHPUnit needs ext-dom, ext-mbstring and ext-xmlwriter; where the host PHP lacks them: docker run --rm -u $(id -u):$(id -g) -v "$PWD":/app -w /app php:8.4-cli vendor/bin/phpunit.
Integration run
scripts/integration.sh drives the binary against a real homeserver: Continuwuity v26.9.1 in Docker with the appservice cpd and the bot @assistent:matrix.test, and the partner dave, a mautrix-go client that plays Element X with cross-signing and "only cross-signed devices" (SendKeysMinTrust = CrossSignedTOFU), behind a small control API. Both come from the spike in site-cpd (.agents/845-e2ee/spike, local and gitignored there), so the script is not CI. Every run starts with a fresh SQLite and a fresh partner device, replaces the bot's identity on that homeserver, sends with dave as the only recipient, and runs the binary at MATRIX_E2EE_LOG=debug.
Run of 2026-10-23, all 38 steps ok. The withheld counts include dave's unverified devices left over from earlier partner restarts.
| Step | Expected | Observed | Result |
|---|---|---|---|
| identity | exit 0, seeds and master key, no server | exit 0, master 127ONhyq… | ok |
| login from seeds | exit 0, master key of the seeds, since saved | exit 0, device ICXUINR0II, master 127ONhyq…, deleted 0 devices | ok |
| dave sees the bot | bot device cross-signed-tofu | {"trust":"cross-signed-tofu","changed":false} | ok |
| login on an existing path | exit 2 | exit 2, store scripts/.integration/bot.db exists; login needs a path without a file | ok |
| decrypt before sync | exit 3, no_session | exit 3, no_session true | ok |
| sync | exit 0, room key as to-device, event in timeline | exit 0, to_device {"m.room.encrypted":1}, timeline [{"body":"Neuer Kontakt: Erika Mustermann, Hauptstraße 5/7","trust":"cross-signed-tofu"}], undecrypted 0 | ok |
| second sync | exit 0, nothing twice | exit 0, to_device {}, timeline 0 | ok |
| decrypt after sync (PHP-escaped slashes) | exit 0, plaintext, cross-signed-tofu, dave's master key | exit 0, body "Neuer Kontakt: Erika Mustermann, Hauptstraße 5/7", cross-signed-tofu, master J639Ovg6… = J639Ovg6… | ok |
| send reply | exit 0, dave gets the session | exit 0, rotated true, {"changed":false,"devices_shared":1,"devices_withheld":15} | ok |
| dave decrypts the reply | plaintext, in_reply_to, cross-signed-tofu | {"body":"Vorschlag: Erika Mustermann","reply_to":"$Xha0A9eDzO2RUf9Gg_0qbrG9FNPzHD-HFdYbCPWe-kY","trust":"cross-signed-tofu"} | ok |
| edit | dave reads m.new_content | exit 0, rotated false, {"new_body":"Vorschlag: Erika Musterfrau","rel_type":"m.replace"} | ok |
| bot message replayed as dave | exit 4, own_session_replay, no plaintext | exit 4, reason own_session_replay, content null | ok |
| send with dave not among the recipients | exit 4, unexpected_members, nothing sent | exit 4, unexpected ["@dave:matrix.test"], pushes 5 → 5 | ok |
| second message without sync | exit 0 | exit 0, body "Zweite Nachricht" | ok |
| sync --timeout 8000 | returns with the event before the timeout | exit 0 after 1044 ms, timeline: ["Während des Wartens"] | ok |
| pin | exit 0 | exit 0 | ok |
| trust --fetch | unchanged, device cross-signed-tofu | exit 0, changed false, devices {"cross-signed-tofu":1,"unverified":15} | ok |
| sync --min-trust after dave's reset | dave's event withheld, no content | exit 0, withheld [{"reason":"below_min_trust","trust_state":"cross-signed-untrusted"}], in timeline 0 | ok |
| decrypt after dave's reset | exit 4, no plaintext, new and first-seen key | exit 4, cross-signed-untrusted, master SqKnV9/B… = SqKnV9/B…, first seen J639Ovg6…, content null | ok |
| send after dave's reset | changed, session withheld | exit 0, rotated true, {"changed":true,"devices_shared":0,"devices_withheld":16} | ok |
| dave cannot read it | decrypt fails at dave | {"ok":false,"error":"failed to get group session: group session has been withheld: m.unverified"} | ok |
| tofu-reset | exit 0, outbound session dropped | exit 0, dropped true | ok |
| exchange after tofu-reset: bot to dave | dave reads it | exit 0, rotated true, {"changed":false,"devices_shared":1,"devices_withheld":15}; dave: {"body":"Wieder lesbar","trust":"cross-signed-tofu"} | ok |
| exchange after tofu-reset: dave to bot | exit 0, cross-signed-tofu | exit 0, cross-signed-tofu, body "Auch von mir wieder" | ok |
| unparsable since | exit 5 where the homeserver rejects it; Continuwuity: full sync | exit 0, since s0_garbage_token → 9282, timeline 8 events again | ok |
| store with a device but no since | exit 5 | exit 5, store holds a device but no since; login again | ok |
| wrong pickle key | exit 5 | exit 5, init: the pickle key does not fit the store: failed to load olm account: decrypt pickle: the message couldn't be decrypted (bad mac) | ok |
| store is a directory | exit 1 (not a lost store) | exit 1, store scripts/.integration: open scripts/.integration: unable to open database file (14) | ok |
| wrong as_token | exit 1 (configuration, not store) | exit 1, init: failed to query own keys to make sure device is properly configured: M_UNKNOWN_TOKEN (HTTP 401): Invalid token | ok |
| store still fine | exit 0 | exit 0 | ok |
| login again from the same seeds | same master key, old device deleted | exit 0, master 127ONhyq… = 127ONhyq…, deleted ["ICXUINR0II"] (the first device) | ok |
| dave sees no identity change | master unchanged, new device cross-signed-tofu | {"changed":false,"trust":"cross-signed-tofu"} | ok |
| pin remembered identity in the fresh store | exit 0 | exit 0 | ok |
| dave to the new device | exit 0, cross-signed-tofu | exit 0, cross-signed-tofu, body "An das neue Gerät" | ok |
| new device to dave | dave reads it as cross-signed-tofu | exit 0, {"changed":false,"devices_shared":1}; dave: {"body":"Vom neuen Gerät","trust":"cross-signed-tofu"} | ok |
| device deleted on the server: sync | exit 5 | exit 5, init: failed to query own keys to make sure device is properly configured: M_FORBIDDEN (HTTP 403): Device does not exist for user or appservice cannot masquerade as this device. | ok |
| device deleted on the server: decrypt | exit 5 | exit 5, init: failed to query own keys to make sure device is properly configured: M_FORBIDDEN (HTTP 403): Device does not exist for user or appservice cannot masquerade as this device. | ok |
| stderr at debug level | no plaintext, seeds or pickle key | 803 log lines, 0 leaks | ok |
Not in the binary
The caller joins rooms, sends typing notifications, reactions and unencrypted messages, and creates rooms with its own client (in password mode with the access token login printed). The binary implements no key backup, interactive verification, key requests, token refresh or MSC4268 key bundles, so messages from before the device's creation stay unreadable.
Open points
- The
rejects sincecase of exit 5 cannot be produced against Continuwuity, which answers an unparsablesincewith a full sync; only a unit test covers it. - Nothing is tested against Synapse. Password mode is tested only against the test homeserver in
cmd/matrix-e2ee/fakehs_test.go(login, the challenges of key upload and device deletion); the user-interactive authentication of Synapse and Continuwuity is implemented as the specification describes it and not yet run against either. sendis not idempotent: a caller that retries after exit 1 can send a message twice when the first attempt reached the homeserver.- The build image is pinned by tag, not by digest.
License
The package is licensed under the EUPL-1.2. The binary statically links Go modules under their own licenses: maunium.net/go/mautrix and go.mau.fi/util (MPL-2.0; unmodified, source at their upstream repositories in the versions named in VERSION and go.mod), modernc.org/sqlite, modernc.org/libc, modernc.org/mathutil and modernc.org/memory (BSD-3-Clause), golang.org/x/crypto, net, sys, text and exp, filippo.io/edwards25519, google/uuid and remyoudompheng/bigfft (BSD-3-Clause), rs/zerolog, tidwall/gjson, sjson, match and pretty, dustin/go-humanize, mattn/go-colorable and mattn/go-isatty (MIT), and petermattis/goid (Apache-2.0).