No description
  • Rust 89.7%
  • TypeScript 3.9%
  • JavaScript 3.3%
  • CSS 1.8%
  • PLpgSQL 0.6%
  • Other 0.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Guillaume Quillery 144b6d9e4b
Some checks failed
CI / fmt, clippy, test (push) Failing after 9s
CI / advisories, licences, bans (push) Failing after 3s
CI / admin console (push) Failing after 1m9s
Image / image (push) Successful in 7m36s
No second factor added from a provider sign-in; smaller logo
A browser signed in through an OpenID Connect provider no longer sees
the security key and authenticator app buttons on the account page, and
the endpoints behind them refuse it: the provider owns that sign-in's
second step. Existing factors can still be removed.

The logo is sized in the markup too, so a cached stylesheet without its
rule no longer stretches it across the card, and is smaller. The
stylesheet URL carries a hash of its content so browsers pick up changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:47:16 +02:00
.github/workflows CI on ZLRunner's linux-amd64 label 2026-10-07 17:12:26 +02:00
admin-ui Admin console: the real logo in the nav, sign-in card and tab 2026-10-06 18:08:17 +02:00
complement Synapse's defaults: link previews, public profiles and media links, every room version 2026-10-07 14:15:08 +02:00
crates No second factor added from a provider sign-in; smaller logo 2026-10-08 22:47:16 +02:00
deploy Security pass: history after leaving, search counts, thumbnails, wake-ups, limits 2026-10-08 20:13:12 +02:00
docs Security pass: history after leaving, search counts, thumbnails, wake-ups, limits 2026-10-08 20:13:12 +02:00
fuzz Synapse's defaults: link previews, public profiles and media links, every room version 2026-10-07 14:15:08 +02:00
nullchat Security pass: history after leaving, search counts, thumbnails, wake-ups, limits 2026-10-08 20:13:12 +02:00
scripts Security pass: history after leaving, search counts, thumbnails, wake-ups, limits 2026-10-08 20:13:12 +02:00
.dockerignore Fuzzing: event authorization, stored events, search queries, thumbnails 2026-10-05 14:01:37 +02:00
.env.example Admin console: React and Vite on nullAuth's design system 2026-10-05 08:37:21 +02:00
.gitignore Run Complement: a test image with registration behind a feature 2026-10-05 14:29:36 +02:00
Cargo.lock Synapse's defaults: link previews, public profiles and media links, every room version 2026-10-07 14:15:08 +02:00
Cargo.toml Bridges: application services, with encryption, for mautrix-whatsapp and mautrix-meta 2026-10-06 14:03:35 +02:00
compose.yaml Security pass: history after leaving, search counts, thumbnails, wake-ups, limits 2026-10-08 20:13:12 +02:00
Containerfile compose.yaml works from a fresh checkout 2026-10-05 23:20:15 +02:00
deny.toml Workspace skeleton: config, database, discovery, CI 2026-10-04 17:19:48 +02:00
logo.svg Admin console: the real logo in the nav, sign-in card and tab 2026-10-06 18:08:17 +02:00
README.md Logo on the server's own pages and in the README 2026-10-08 22:31:26 +02:00
rust-toolchain.toml Workspace skeleton: config, database, discovery, CI 2026-10-04 17:19:48 +02:00
rustfmt.toml Workspace skeleton: config, database, discovery, CI 2026-10-04 17:19:48 +02:00

nullChat

nullChat

A self-hosted chat server for a homelab or a small organisation: rooms, direct messages, and audio and video calls, all spoken in Matrix. That means Element, Element X, FluffyChat and the other Matrix apps work with it; Element Web, self-hosted beside it, is its web client.

It does not federate, deliberately: it talks to its own users and to no other server. People sign in with a local account or through any OpenID Connect provider, nullAuth and zeroAuth included.

Status: in use. It runs one deployment, with Element Web in browsers and Element X on phones: encrypted chat, push notifications and calls all work there. It has had no external security audit; what it defends against, and what it does not, is in docs/threat-model.md.

What it does

  • Sign-in: local accounts with a password, Matrix's OAuth 2.0 API, and any OpenID Connect provider. Two-step verification with an authenticator app or a security key, with recovery codes for a lost one.
  • Rooms and sync: classic /sync and Simplified Sliding Sync (MSC4186), threads, edits, reactions, search, read receipts, typing, presence, public rooms and room addresses, room upgrades, and every room version (1 to 12).
  • Encryption: the server side of end-to-end encryption (keys, cross-signing, to-device messages, key backup, dehydrated devices), on by default for direct messages and private rooms.
  • Media: never served as active content, with thumbnails, asynchronous uploads, and link previews fetched by the server (public addresses only).
  • Calls: MatrixRTC on LiveKit; nullChat is Element Call's token service and keeps delayed events (MSC4140) itself. A member who hands over their "leave" (MSC4195) drops out of the call within seconds of losing LiveKit.
  • Phones: push notifications that carry event IDs, never message text.
  • Bridges: mautrix-whatsapp and mautrix-meta as application services, with end-to-bridge encryption, receipts, typing, backfill and double puppeting.
  • Accounts: people manage their sessions, password and second factors at /account/, download their data, and delete or erase their account. A housekeeping job forgets addresses, old audit entries and, if rooms ask, old messages (docs/gdpr.md).
  • Administration: an admin API on its own listener (127.0.0.1:8010) with its console (admin-ui/), for an administrator signed in with two factors; every change and every look at someone's details is audited.

Matrix's own test suite, Complement, runs against it; every test that still fails is one for something nullChat does not do on purpose, such as federation (docs/complement.md).

How it is built

Server Rust, #![forbid(unsafe_code)], axum, ruma, PostgreSQL 18
Sync classic /sync, and Simplified Sliding Sync (MSC4186) for Element X
Sign-in Matrix's OAuth 2.0 API, local accounts (argon2id, TOTP, WebAuthn) and upstream OIDC
Encryption end-to-end by default for direct messages and private rooms
Calls MatrixRTC on LiveKit, with its built-in TURN
Web client Element Web, unmodified, self-hosted and configured for this server only
Admin console React and Vite, the same stack as nullAuth's console
GDPR data export, erasure, retention per server and room, no third-party requests beyond the push gateway a phone's app registers (docs/gdpr.md)

It was built in this order: skeleton → sign-in → rooms and sync → encryption and media → sliding sync → calls → admin → Element Web → GDPR jobs and hardening, then presence, push, bridges and what Complement asked for. Choices that were not obvious are recorded in docs/decisions.md. The fuzz targets are in fuzz/.

Running it

cp .env.example .env              # then edit
set -a; . ./.env; set +a
cargo run -- migrate              # as the owning role
scripts/dev-db.sh init            # let nullchat_app log in
cargo run -- user create alice --admin   # prints a generated password
(cd admin-ui && npm ci && npm run build) # the admin console, for admin.ui_path
scripts/element-web.sh            # Element Web on :8080, for this server
cargo run                         # serves on 127.0.0.1:8008, probes on :8009,
                                  # the admin API on :8010

The admin API asks for two-step verification. Before signing in to it, turn that on at /account/, or sign in through a provider that reports multi-factor sign-ins.

scripts/dev-db.sh bootstrap creates the nullchat role and the nullchat and nullchat_test databases on a development cluster. For containers, see compose.yaml, which with a .env is a whole deployment (a nullContainer stack, for one): it pulls the image, generates its own secrets, and needs no checkout. The image workflow pushes the image to src.zerolatitude.dev/gquillery/nullchat on every push to main.

The WhatsApp and Meta (Facebook, Messenger, Instagram) bridges are part of that stack: add whatsapp and/or meta to COMPOSE_PROFILES and run docker compose up -d. Each is set up on every start: configured for this server, encrypted, with read receipts, typing and double puppeting, and registered with nullChat. The admin console's Bridges page shows each one, whether events are reaching it and why not, and pings it. Everyone on the server may use them; NULLCHAT_BRIDGE_ADMINS names who administers them. Then start a chat with @whatsappbot:<server> and send login qr, or with @metabot:<server> and send login. A bridge's own settings live in its volume (/data/config.yaml); the stack changes only what it must. Turning a bridge off leaves its registration, so remove it and restart nullChat:

docker compose run --rm --no-deps --entrypoint rm whatsapp-setup /bridges/mautrix-whatsapp.yaml
docker compose restart nullchat

Two settings matter before the first start:

  • server.name is the part after the colon in @alice:example.org. It is written into the database on first start, and the server refuses to start under any other name afterwards. Every user, room and upload ID contains it, so it cannot be changed.
  • database.url must name the unprivileged nullchat_app role. The server refuses to serve as a superuser or as the owner of any table. The owning role goes in database.admin_url and is used only for migrations.

Behind a reverse proxy, set http.trusted_proxies. Without it every request appears to come from the proxy, so a few wrong passwords from anyone throttle logins for everyone.

Every setting can come from the environment (NULLCHAT_SERVER__NAME), and any secret from a file (NULLCHAT_DATABASE__URL_FILE). See deploy/nullchat.toml.example.

Tests

cargo test --workspace            # database suites skip without a test database

Against a running server, the real matrix-js-sdk (the library behind Element Web) can hold a conversation between two accounts:

cd scripts/client-smoke && npm install
NULLCHAT_URL=http://localhost:8008 ALICE=alice ALICE_PASSWORD=… BOB=bob BOB_PASSWORD=… node smoke.mjs
NULLCHAT_URL=… ALICE=… ALICE_PASSWORD=… BOB=… BOB_PASSWORD=… node e2ee.mjs

With a mautrix bridge running against it, encryption on, an encrypted DM with its bot (commands sent and answers read, both encrypted):

NULLCHAT_URL=… ALICE=… ALICE_PASSWORD=… BOT=@whatsappbot:example.org PREFIX='!wa' node bridge.mjs

With a LiveKit server beside nullChat (the calls profile in compose.yaml), a call is joined and left the way Element Call does it, and checked from LiveKit's side:

NULLCHAT_URL=… ALICE=… ALICE_PASSWORD=… BOB=… BOB_PASSWORD=… \
LIVEKIT_KEY=nullchat LIVEKIT_SECRET=… node call.mjs

matrix-js-sdk's own call code (the code inside Element Call) joins a call in an encrypted room, and each side must see both memberships, hold both media keys, and stay connected for 45 seconds — what Element Call needs before it sends any media:

NULLCHAT_URL=… ALICE=… ALICE_PASSWORD=… BOB=… BOB_PASSWORD=… node rtc-keys.mjs

matrix-rust-sdk, the engine inside Element X, does the same over Sliding Sync, with encryption:

cd scripts/sliding-smoke
NULLCHAT_URL=http://localhost:8008 ALICE=alice ALICE_PASSWORD=… BOB=bob BOB_PASSWORD=… cargo run --release

and resets a cross-signing identity the way Element X does, approved on the account page:

NULLCHAT_URL=http://localhost:8008 USER=alice PASSWORD=… cargo run --release --bin reset

The database suites run when NULLCHAT_TEST_DATABASE_URL and NULLCHAT_TEST_ADMIN_DATABASE_URL are set (see .env.example). CI runs formatting, clippy, both suites against PostgreSQL 18, and cargo deny.

Licence

AGPL-3.0-or-later.