Skip to content

Whispee

Whispee is an end-to-end encrypted messenger — web, desktop and mobile from one Rust crypto core, one Axum server, one React client. What makes it unusual is not the feature list: it is built on MLS (RFC 9420) rather than the Signal protocol, and every design decision in it is argued in writing, including the ones that turned out badly.

⚠️ Status: a learning and demonstration project

This project is not meant to protect real users, and certainly not users at risk. It has received no external audit and will not receive one. An E2EE protocol that is correct on paper fails in practice on details only an audit surfaces. For communications that actually matter: use Signal.

Inspired by

WhatsApp, Signal and Telegram. Whispee reimplements the ideas those applications made ordinary — end-to-end encryption, multi-device accounts, groups, metadata that does not leak more than it must — on top of MLS instead of the Signal protocol. The comparisons made throughout the documentation are threat-model comparisons, not claims of parity. Whispee is not their equal and does not try to be.

What works today

Area State
1-to-1 and group messaging MLS over OpenMLS 0.8.1; a 1-to-1 is a group of two
Multi-device accounts Every device is a group member, attested by the account key
Groups, roles, removal One admin, moderators; roles carried in a group-context extension
Device revocation, account rotation Signed certificates the other members verify themselves
Key transparency RFC 6962 append-only Merkle log, with client-side gossip
Metadata defences Length padding in doubling steps, sealed sender via a group post MAC
Signalling Delivery and read receipts, typing, presence, reactions — each disableable
Attachments Per-file AES-256-GCM key carried inside the MLS message, padded into doubling buckets
Audio calls 1-to-1 and group, through a media server that cannot hear them — frames encrypted under a key derived from the MLS epoch. Off unless a deployment configures one, and it leaks more than a message does: see docs/THREAT-MODEL.md § 4ter
Local lock Argon2id (64 MiB, 3 passes), unlock key → master key indirection, re-locks when left alone
Account recovery Twelve words, plus an opt-in escrow — a password (Argon2id, 256 MiB) or a WebAuthn passkey. Off by default: it puts the account key on the server, encrypted, and says so before the field
Disappearing messages On by default: seven days. The lifetime is a group-context extension, so every member agrees on it; the server never learns it. Not enforceable on the other side — see docs/THREAT-MODEL.md
History vault On by default, encrypted under a key derived from the recovery phrase — and off for any conversation with a lifetime, which is what makes disappearing mean anything. Such a conversation does not survive the loss of every device
Storage quota 256 MiB per account by default, charged on vault writes and attachment uploads, credited back when a purge deletes. Envelopes are outside it: charging a sealed post would mean naming its sender — see docs/ROADMAP.md
Web, desktop, mobile Vite 7 + React 19; Tauri 2 wraps the same build for Linux, Windows, macOS, Android and iOS. Each one is pointed at a server on first launch — there is no central service
Installable web client A manifest, icons and a service worker that caches what is addressed by its content. It starts with no network, carries an unread badge, and — the part that is not cosmetic — is what lets iOS subscribe to push at all
Push notifications Web Push, off until a deployment names a contact in VAPID_SUBJECT. The wake-up carries no text, no sender and no group id — the worker cannot decrypt, so it says only that something arrived
Verifiable web client The bundle belongs to no deployment, so one published manifest of hashes describes every instance. CI attests it to GitHub; an extension compares what the browser actually received. See docs/THREAT-MODEL.md § 4quinquies for what that establishes and what it does not
Deployment deploy/ — Postgres, the server, and Caddy terminating TLS on one origin. See docs/DEPLOY.md
Reproducible, signed releases scripts/release.sh, scripts/verify-release.sh
Desktop installers .deb, .rpm, AppImage, .msi, NSIS and .dmg, built by CI on a tag with a SHA256SUMS and a Sigstore attestation. Unsigned by the platforms: Windows shows SmartScreen and macOS asks for a right-click → Open. The .dmg is Apple Silicon only

What does not work

  • Push reaches browsers, not the packaged mobile app — and the installed web client is therefore better at notifications on a phone than the native one, which is a reversal worth stating plainly. Web Push works end to end: a browser subscribes, the server signs a VAPID token, a notification arrives with the tab closed, and iOS can now subscribe because the client is installable. FCM and APNs are not written — not for want of tooling any more, since several Tauri push plugins now exist, but because APNs cannot be exercised without a paid Apple Developer membership and there is no Android device here. A Tauri webview has no service worker either, so a packaged build has no background wake-up path at all. The wake-up carries no text, no sender and no group id. See docs/ROADMAP.md.
  • Nothing reaches a watch. An Apple Watch or a Wear OS device shows the notifications its phone received, so this waits entirely on the line above — and even then, a notice that says only "New message" is not much of a wrist.
  • Biometric unlock has never been executed. The code exists; not one line of it has run. There is no Android NDK and no physical device on the development machine, so even the compilation of its dependency is unconfirmed.
  • The mobile builds are the only thing still validated by hand. test.yml now runs the Rust and client suites, clippy, and the check that the committed WebAssembly matches crates/crypto-wasm. android.yml and ios.yml stay manual or main-only to save Actions quota, so no mobile artefact is produced on a pull request.
  • Mobile builds, backups, account deletion, post-quantum protection: see docs/ROADMAP.md and the known-limitations table in docs/THREAT-MODEL.md.

Quickstart

Requires Rust 1.95 or later (edition 2024), Node 22, pnpm 11, wasm-pack, and Docker.

# 1. Database — PostgreSQL 17 on host port 55432, chosen to avoid a system
#    Postgres (5432) or a local Supabase (54322).
docker compose up -d

# 2. Configuration. The committed defaults point at that container.
cp .env.example .env

# 3. Server — listens on 127.0.0.1:8787. The script passes .env through, which
#    the server does not read itself, and gives the branch its own database and
#    port. Everything the file defines reaches the server: the two values the
#    script computes — the database and the address — are the only ones it
#    overrides.
./scripts/dev-server.sh

# 4. Client, in a second terminal. `wasm` builds crypto-core to WebAssembly
#    and copies it into public/.
(cd apps/web && pnpm install && pnpm run wasm)
./scripts/dev-web.sh  # http://localhost:5173, pointed at the server above

Both launchers derive their ports from the branch checked out, so two branches can run at once: main keeps 8787 and 5173, the next branch takes 8788 and 5174. See docs/BUILD.md.

Tests are run in release, always:

cargo test --release

OpenMLS 0.8.1 runs a debug_assert!(false) before returning its decryption error. In a debug build, one altered byte in transit panics the process instead of being rejected — a remotely triggerable denial of service. Never ship a debug build. The reasoning is in CONTRIBUTING.md.

For the desktop application, the Tauri path traps, the mobile targets, release signing and everything else: docs/BUILD.md.

Documentation

Every design decision is written down, with what it costs and what it does not solve.

Document What it holds
docs/ARCHITECTURE.md The crates, the apps, the server, and why the crypto core is single
docs/PROTOCOL.md Wire-level detail: attestations, signature domains, sealed sender, the transparency log
docs/THREAT-MODEL.md What the adversary is assumed to do, and the full known-limitations table
docs/SECURITY-PROPERTIES.md The properties claimed, the ones deliberately not claimed, and the tests that pin them
docs/specs/2026-08-22-recovery-escrow.md Why recovery by password exists, and everything it gives up
docs/ACCESSIBILITY.md What holds the interface reachable without a mouse, and what is still missing
docs/BUILD.md Building every target, reproducible releases, verification
docs/DEPLOY.md Putting this on a host other people reach, and what such a deployment still does not do
docs/ROADMAP.md What is planned, what is half-done, and what has been ruled out
CONTRIBUTING.md Branches, the invariants a patch must not break, tests, commit style
SECURITY.md Reporting a vulnerability, and the honest limits of the response
CODE_OF_CONDUCT.md Contributor Covenant 2.1

Licence

GNU Affero General Public License, version 3 or later. The full text is in LICENSE.

Why the Affero variant rather than the GPL

Because of the one thing it adds: running a modified version as a network service counts as distributing it. A messenger is used over a network and almost never installed by the person it serves, so the ordinary GPL would let somebody host a changed build — with the crypto quietly weakened, say — and owe nobody the source. That is precisely the substitution this project spends a manifest, a workflow and a browser extension trying to make detectable; a licence that permitted it in law while the code fought it in practice would be an odd pair.

publish = false stays on every crate. The licence says what may be done with the source; it does not make any of this fit to publish to a registry, and the status section above has not changed.

About

End-to-end encrypted messenger built on MLS (RFC 9420) rather than the Signal protocol. Rust crypto core, Axum server, React client, and a web build you can check against an attested manifest. Unaudited learning project — for anything that matters, use Signal.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages