Self-hosted messenger with client-side encryption and durable delivery.
Direct & group chat · Encrypted files · Voice & video notes · 1:1 calls · Multi-device E2EE
Русская версия · Why · Product · Protocol · Run · Security · Operate
Most team messengers are great collaboration tools and weak confidentiality tools: the operator can read the room. Signal is the opposite — strong encryption, not a workspace you host. Chaos is the overlap: a messenger you run, whose server is not in the plaintext path, and whose delivery path is designed for the broker to die.
It uses an original X3DH-inspired handshake and Double Ratchet-style protocol on WebCrypto. It is not Signal, not a Signal Protocol clone, and has not been independently audited. Treat it as a serious engineering product, not as a finished high-risk vault.
| Typical self-hosted chat | Signal-class E2EE | Chaos | |
|---|---|---|---|
| You run the servers | Yes | No | Yes |
| Operator can read messages | Usually yes | No | No |
| Groups, files, voice, desktop | Yes | Constrained | Yes |
| Per-device keys, Safety Number | Rare | Yes | Yes |
| Outbox + durable reconnect | Sometimes | Yes | First-class |
| Independent crypto audit | Varies | Yes | Not yet |
The interesting problem is not “encrypt a string”. It is: encrypt for every device, survive at-least-once delivery, recover after a disconnect, and still refuse to trust a silently rotated identity key.
| Capability | Web | Desktop | Status |
|---|---|---|---|
| Direct chats, groups, saved messages | Yes | Yes | Shipped |
| Replies, edits, delete, reactions | Yes | Yes | Shipped |
| Delivery / read receipts, typing | Yes | Yes | Shipped |
| Disappearing messages | Yes | Yes | Shipped |
| Encrypted photos and files | Yes | Yes | Shipped |
| Voice messages (hold to record, lock, cancel) | Yes | Yes | Shipped |
| Video notes | Yes | Yes | Shipped |
| Send preview and in-chat paging | Yes | Yes | Shipped |
| Multi-device encrypted fan-out | Yes | Yes | Shipped |
| Safety Number / QR verification | Yes | Yes | Shipped |
| Encrypted key backup | Yes | Yes | Shipped |
| Web Push | Yes | — | Shipped |
| 1:1 audio & video calls | Dev | Dev | Experimental |
| Group calls / production TURN | — | — | Roadmap |
| Independent cryptographic audit | — | — | Pending |
Calls are on in local development. Production stays off until TURN sits in front of WebRTC. Media is DTLS-SRTP; signaling (who called whom, SDP, ICE) still traverses the server.
Each device has its own identity. A send encrypts once per destination device, including your other devices. The server routes ciphertext. It never sees the AES-GCM key.
sequenceDiagram
autonumber
participant A as Alice device
participant API as Chaos API
participant DB as PostgreSQL
participant K as Kafka
participant B as Bob device
A->>API: Fetch Bob's pre-key bundles
API-->>A: Public identity, signed pre-key, one-time pre-key
Note over A: Verify signature · X3DH-inspired session · Double Ratchet
A->>A: AES-256-GCM envelope per device, versioned AAD
A->>API: Ciphertext envelopes
API->>DB: Message + envelopes + outbox (one transaction)
DB-->>K: Publish after commit
K-->>B: Durable device event, then STOMP notify
B->>B: Verify identity · ratchet · decrypt · store locally
Authenticated associated data binds ciphertext to protocol type, chat id, message index, previous chain length and ratchet public key. Tamper with the header, AES-GCM fails.
Realtime is the fast path. Correctness is the device event log: after reconnect the client asks for everything after its cursor and ignores duplicate eventIds. If Kafka is down, outbox rows stay pending and retry. There is no in-process “just publish it” fallback.
flowchart LR
SEND[Send] --> TX[One DB transaction]
TX --> MSG[(Messages + envelopes)]
TX --> OUT[(Outbox)]
OUT --> K[Kafka]
K --> LOG[(Device event log)]
LOG --> WS[STOMP notify]
LOG --> SYNC["GET /api/realtime/sync"]
WS --> APPLY[Decrypt locally]
SYNC --> APPLY
APPLY --> CURSOR[Advance cursor]
Typing and presence are ephemeral. They do not go through the outbox.
| May store or observe | Must never receive |
|---|---|
| Account and profile metadata | Message plaintext |
| Device identifiers | Private identity keys |
| Public identity keys and pre-key bundles | Private signed / one-time pre-keys |
| Chat membership and authorization | Ratchet message keys |
| Encrypted envelopes and attachment blobs | Attachment plaintext |
| Delivery timing and ciphertext size | Backup passphrase |
| Call signaling (peers, SDP, ICE) | Call media plaintext |
Chaos does not hide metadata. Membership, device count, timing and size leak. That is the same class of tradeoff Signal documents: content is protected, traffic analysis is not.
A compromised OS, a malicious browser extension, injected JavaScript on a trusted origin, an unlocked Electron session, screen or clipboard capture.
A new device starts unverified. Safety Number / QR moves it to verified. If that identity key later changes, the client does not shrug — it enters KEY_CHANGED until the user re-verifies or blocks.
stateDiagram-v2
[*] --> UNVERIFIED: new remote device
UNVERIFIED --> VERIFIED: Safety Number / QR
VERIFIED --> KEY_CHANGED: identity key changes
KEY_CHANGED --> VERIFIED: explicit re-verify
KEY_CHANGED --> BLOCKED: user rejects
Device enrollment uses a short-lived one-time registration token, consumed with GETDEL. That token is not the cryptographic identity. The key bundle is generated on the client.
Encrypted on the device with a passphrase-derived AES-GCM key. The passphrase never leaves the machine. A restore brings back identity material. It does not promise local history, consumed one-time pre-keys, or every old ratchet session.
Refresh tokens are single-use. Reuse of a token family is treated as theft. Access tokens are not a substitute for a device identity.
| Layer | Choice |
|---|---|
| Clients | React 18, Vite, Electron, WebCrypto, IndexedDB |
| Protocol | TypeScript DTO gate, original Double Ratchet-style engine |
| API | Java 17, Spring Boot 3.5, Spring Security |
| Data | PostgreSQL 16, Flyway, Redis 7 |
| Delivery | Transactional outbox, Kafka / Redpanda, native STOMP |
| Edge | Caddy, Nginx |
| Observe | Actuator, Prometheus, Grafana, Loki |
| Ship | Docker Compose, Kubernetes, GitHub Actions, GHCR, SBOM, Trivy |
backend/ Spring API, Flyway, tests
frontend/ Web + Electron + crypto engine
infra/ Caddy, Prometheus, Loki
k8s/ Stateless production manifests
docs/ Runbooks and production checklist
Docker Engine, Compose v2, ~4 GB RAM.
git clone https://github.com/vaazhen/chaos-e2ee-messenger.git
cd chaos-e2ee-messenger
cp .env.example .envFill every CHANGE_ME:
openssl rand -base64 32 # POSTGRES_PASSWORD
openssl rand -base64 32 # REDIS_PASSWORD
openssl rand -base64 48 # JWT_SECRET
openssl rand -base64 32 # GRAFANA_ADMIN_PASSWORDDOMAIN=localhost
CORS_ORIGINS=https://localhost
CHAOS_DEMO_ENABLED=false
KAFKA_BOOTSTRAP_SERVERS=localhost:19092docker compose up --build -dOpen https://localhost. Create two accounts, send a message. Caddy uses a local CA on localhost and a public certificate when DOMAIN is real.
docker compose down # stop
docker compose down -v # stop and wipe volumescd backend && docker compose -f docker-compose.dev.yml up -d && ./mvnw spring-boot:run
cd frontend && cp .env.example .env && npm ci && npm run devAPI at http://localhost:8080, app at http://localhost:5173. Dev compose includes PostgreSQL, Redis, Redpanda and coturn so two browsers on one machine can complete a call.
cd frontend && cp .env.electron.example .env.electron && npm run electron:devPackaged desktop builds require absolute https / wss endpoints and should be signed.
cd backend && ./mvnw --batch-mode --no-transfer-progress verify
cd ../frontend && npm ci && npm run lint && npm run typecheck && npm run test:coverage -- --run && npm run buildk8s/ deploys the stateless app. PostgreSQL, Redis, Kafka, object storage and secrets are yours.
kubectl kustomize k8s/
kubectl apply -k k8s/k8s/README.md · docs/PRODUCTION_READINESS.md · docs/runbooks/
CI verifies backend and frontend, runs CodeQL, publishes immutable GHCR images with SBOM/provenance, and gates HIGH/CRITICAL findings with Trivy. Staging and production deploys are protected environments.
Environment
| Variable | Purpose |
|---|---|
POSTGRES_PASSWORD |
Database |
REDIS_PASSWORD |
Redis |
JWT_SECRET |
JWT signing secret |
DOMAIN |
Public hostname for Caddy |
CORS_ORIGINS |
Exact trusted web origin |
KAFKA_BOOTSTRAP_SERVERS |
Kafka-compatible brokers |
CHAOS_ATTACHMENTS_MAX_BYTES |
Max encrypted upload |
CHAOS_CALLS_ENABLED |
1:1 signaling; off outside dev |
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY |
Web Push |
See .env.example, backend/.env.example, frontend/.env.example.
- Finish the strict TypeScript crypto migration
- Production object storage for ciphertext attachments
- Production TURN, hardened call state, group calls
- Independent pentest and cryptographic review
- Formal protocol specification and test vectors
Small pull requests. Before opening one:
cd backend && ./mvnw verify
cd ../frontend && npm run lint && npm run typecheck && npm run test:coverage -- --run && npm run buildA security-sensitive change should state the invariant, the failure, tests for success/replay/tamper, and any compatibility impact. Report vulnerabilities privately until a fix exists.