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.
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.
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.
| 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 |
- 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.ymlnow runs the Rust and client suites, clippy, and the check that the committed WebAssembly matchescrates/crypto-wasm.android.ymlandios.ymlstay manual ormain-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.
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 aboveBoth 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 --releaseOpenMLS 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.
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 |
GNU Affero General Public License, version 3 or later. The full text is in LICENSE.
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.