No description
Find a file
2026-06-04 15:13:44 +02:00
docs pass through undecrypted messages so sync works 2026-05-23 11:16:51 +02:00
src unmtx owns the refresh token 2026-06-04 15:13:44 +02:00
tests unmtx owns the refresh token 2026-06-04 15:13:44 +02:00
.gitignore Second commit 2026-05-22 19:20:40 +02:00
Cargo.lock i give up 2026-05-24 04:10:04 +02:00
Cargo.toml i give up 2026-05-24 04:10:04 +02:00
README.md more^7 2026-05-23 13:44:52 +02:00

unmtx

unmtx is a local Matrix E2EE proxy daemon for bots. Bots use the normal Matrix Client-Server HTTP API, point their homeserver/base URL at unmtx, and do not need to implement Matrix end-to-end encryption themselves.

bot -> http://127.0.0.1:8009 -> unmtx -> real Matrix homeserver

The design is proxy-first. unmtx does not implement a Matrix homeserver and does not recreate general homeserver semantics. It forwards ordinary Matrix endpoints unchanged, and only uses the Matrix Rust SDK where E2EE correctness requires local device state: login/session capture, /sync, encrypted-capable history, and plaintext room sends that must be encrypted before reaching the upstream homeserver.

The listener is intentionally restricted to loopback IP literals. Do not expose it on 0.0.0.0.

Trust Model

unmtx is designed for trusted bots running on the same host as the proxy. A local process that can connect to the listener can use the proxied Matrix session and can cause unmtx to send Matrix requests with that session's authority.

There is no multi-tenant isolation, local authentication layer, browser-facing CSRF protection, or remote-user access model. Run one proxy store per bot/device identity and protect store_path as secret local state.

Configuration

Minimal unmtx.toml:

listen_host = "127.0.0.1"
listen_port = 8009
homeserver_url = "https://matrix.example.org"
store_path = "./unmtx-store"
secret_storage_key_file = "./recovery-key"

Put your Matrix recovery key, or Secret Storage passphrase, in the file named by secret_storage_key_file. The file is read locally on startup/login and is used to import cross-signing secrets and the Megolm backup key. Keep it private:

printf '%s\n' 'YOUR-RECOVERY-KEY' > ./recovery-key
chmod 600 ./recovery-key

CLI overrides:

unmtx --config unmtx.toml \
  --homeserver-url https://matrix.example.org \
  --store-path ./unmtx-store

homeserver_url is required. It must be an http:// or https:// URL with a host and without credentials, query string, or fragment. A trailing slash is normalized away. store_path defaults to ./unmtx-store.

unsafe_auto_confirm_verifications = true is optional and only for trusted local deployments. Prefer secret_storage_key_file when you have a recovery key.

Session And Key Storage

Start unmtx, then let the bot log in through the proxy using the normal Matrix login endpoint:

POST /_matrix/client/v3/login

On successful upstream login, unmtx stores the returned user_id, device_id, and access_token under store_path/session.json, restores a Matrix SDK client for that device, and uses store_path/sdk-store for the SDK persistent SQLite store.

unmtx-store/
  session.json
  sdk-store/

Back up store_path with the same care as a Matrix client profile. It contains access tokens and Matrix E2EE state.

After login, unmtx imports secrets from secret_storage_key_file when set. This is what lets the SDK enable key backup and decrypt encrypted rooms without manual device verification.

If the upstream login succeeds before a local device exists but the SDK store cannot be opened, unmtx moves that pre-session store aside as sdk-store.failed.<pid>.<timestamp> and retries activation with a fresh SDK store. Once session.json exists, the SDK store is considered bound to that Matrix device and is not automatically replaced. A stored device whose SDK store cannot be restored is a hard local error, because replacing it would lose the crypto state needed to decrypt messages for that device.

Proxy Behavior

See docs/architecture.md for the detailed proxy boundary.

Route class Examples Behavior
Generic client API /_matrix/client/v3/profile/*, room metadata, aliases, membership, receipts, typing Forward upstream with hop-by-hop headers stripped and end-to-end headers such as Authorization preserved.
Media API /_matrix/media/* Forward upstream unchanged. Encrypted attachment metadata belongs to room event content; media routes are not rewritten or decrypted.
Login/session POST /login, POST /logout, POST /logout/all Forward upstream; successful login restores local SDK session, successful logout clears it.
Local identity GET /account/whoami Answer from the stored session when loaded; otherwise forward upstream.
Sync GET /sync Use Matrix SDK Client::sync_once so to-device keys, device lists, room state, and encrypted events update local crypto state before bot-visible JSON is returned. Events the SDK still cannot decrypt are returned as their original m.room.encrypted events so the bot can ignore them and advance since.
Message history GET /rooms/{roomId}/messages Use Matrix SDK Room::messages; requires a local session and returns SDK-decrypted events where possible, preserving still-undecryptable chunks as m.room.encrypted.
Room send PUT /rooms/{roomId}/send/{eventType}/{txnId} Use Matrix SDK Room::send_raw for message-like plaintext sends; preserve transaction IDs and cache successful responses. Already encrypted m.room.encrypted sends pass through.
Other local paths anything outside /_matrix/client/ and /_matrix/media/ Reject with a Matrix-shaped M_NOT_FOUND.

Incoming encrypted events are decrypted for the bot when the SDK has the needed keys. If the SDK cannot decrypt an encrypted timeline or history event because a room key may still be in-flight, unmtx performs short crypto warm-up syncs that ignore room timeline data and retries decryption. If the key is withheld, trust-gated, or still unavailable after retry, unmtx returns the original m.room.encrypted event so the bot can ignore it without pinning /sync on the same since token. Outgoing message-like sends fail closed with Matrix-shaped errors when the SDK cannot identify the room, cannot prepare crypto state, or cannot send.

Live E2EE Smoke Test

The ignored live test in tests/live_e2ee_pipeline.rs can be run manually against a real homeserver account. Use disposable credentials and, for the send check, an encrypted room the account has already joined:

UNMTX_LIVE_E2EE=1 \
UNMTX_LIVE_HOMESERVER_URL=https://matrix.example.org \
UNMTX_LIVE_USERNAME=bot-user \
UNMTX_LIVE_PASSWORD='secret' \
UNMTX_LIVE_ROOM_ID='!room:example.org' \
cargo test --test live_e2ee_pipeline -- --ignored --nocapture

UNMTX_LIVE_ROOM_ID is optional. Without it, the test logs in through unmtx and checks SDK-backed /sync shape. With it, the test also sends an m.room.message through the SDK send path and syncs again. Set UNMTX_LIVE_SYNC_TIMEOUT_MS if the homeserver needs a nonzero sync wait.

Limits

  • unmtx is not a Matrix homeserver, multi-user reverse proxy, authorization gateway, or browser-facing service.
  • /sync and /messages shaping targets common bot fields. Some optional upstream fields are not mirrored.
  • Historical decryption depends on available room keys. Configure secret_storage_key_file to let the SDK use Matrix key backup.
  • --unsafe-auto-confirm-verifications accepts and confirms SAS device verifications without human comparison. This can make other devices treat the unmtx Matrix device as verified, but it deliberately bypasses the security purpose of SAS verification and should only be used for trusted local bot deployments.
  • Media routes do not decrypt encrypted attachments.