eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– Eleven Messenger
2
3A small, self-hostable messenger β€” DMs and group chats, no logins, no passwords,
4just secret invite links. Messages are **end-to-end encrypted**: the server only
5ever sees ciphertext, so a stolen database or server snapshot exposes no message
6content. (It does see metadata β€” screen names and who shares a thread with whom;
7see Trade-offs.)
8
9> **`lchat`** is the engine β€” the Go binary + CLI, and the name used throughout
10> this repo. **Eleven Messenger** is the product built on it (the web client plus
11> the native iOS/macOS apps).
12
13## How the encryption works
14
15- Each person has their own **keypair** (ECDH P-256), generated in the browser on
16 first join. The public key lives in the roster; the private key never leaves the
17 device (it ships, encrypted, only via an "add a device" link).
18- Every **thread** β€” a DM (2 people) or a group (N) β€” has its own **AES-GCM key**,
19 wrapped to each member's public key (ECIES). Only members can unwrap it.
20- Content shared with everyone (e.g. profile photos) uses one **per-instance key**,
21 wrapped to each member the same way β€” so even that reaches the server only as a
22 blob it can't open.
23- The browser encrypts every message (including the sender's screen name) before
24 sending, and decrypts on receipt. The server stores and relays only opaque blobs
25 plus key material it can't open.
26- An invite link carries a one-time secret in its `#fragment` (e.g.
27 `/join/<name>#<secret>`) used to bootstrap the joiner's keys β€” never sent to the
28 server. The **admin token authorises roster management only; it is not a
29 decryption key.** Even a full copy of the database reveals no message content.
30
31**Trade-offs (inherent to this model):**
32
33- Membership is the boundary, enforced cryptographically: to read a thread you
34 must be one of its members (hold its wrapped key).
35- Removing a member stops the server delivering new messages to them, but doesn't
36 claw back a key they already hold (no forward secrecy on leave).
37- If a member loses their device with no backup/add-device link, their key
38 material β€” and the threads only they could decrypt β€” is gone.
39- **Metadata is not encrypted.** The server can't read message content, but the
40 roster stores each member's screen name in clear (separate from the encrypted
41 copy carried inside each message), and the server necessarily knows who shares
42 a thread with whom in order to route messages. So a server compromise reveals
43 the social graph and names β€” just not what anyone said.
44- **Unsending leaves a dated marker.** Deleting a message really deletes the
45 ciphertext, but the server keeps a small tombstone row β€” which thread, which
46 message id, when β€” so members' devices that were offline at the time still
47 learn to drop their local copy. The tombstone holds no content and no key
48 material, but it is a durable record *that* a deletion happened (the server
49 transiently knew that anyway β€” it performed the delete).
50- **Key distribution trusts the server.** Public keys are handed out by the
51 server's roster and aren't yet verified out of band (no safety-number check).
52 A *passive* compromise β€” a stolen database β€” can't use that (the content
53 guarantee above holds), but an *active* malicious server could substitute a
54 public key to intercept *new* conversations. Out-of-band key verification is
55 planned to close this.
56- **A bot is a member whose "device" is a machine you choose.** Adding a bot
57 (see `agent/`) to a thread hands that thread's key to the machine the bot
58 runs on β€” so whoever controls that machine holds the key to those
59 conversations, exactly as if they were a member. Threads without a bot are
60 untouched. Crucially, the bot doesn't have to live on the chat server: it's a
61 pure outbound client (it dials the instance and the model API; nothing ever
62 dials it β€” no open ports), so you can run it on any machine you already
63 trust β€” a home server behind your router, or a box on a private network
64 whose only way out is to your instance and the model API. Run it there and a
65 fully compromised chat server still yields nothing: the keys are on a
66 machine an attacker can't even reach.
67
68## Run it
69
70```sh
71go run . # listens on :1747, creates lchat.db
72```
73
74On first start it prints an **admin link** containing your admin token β€”
75open it once and keep it secret:
76
77```
78Admin link (open once, keep it secret):
79http://localhost:1747/admin#token=...
80```
81
82Flags / env:
83
84- `-addr :1747` β€” listen address
85- `-db lchat.db` β€” database file path
86- `-reset-admin` β€” generate and print a fresh admin token
87- `LCHAT_ADMIN_TOKEN=...` β€” set a fixed admin token instead of a generated one
88
89## Build a single binary
90
91The frontend and the SQLite engine are compiled in (pure-Go SQLite, no cgo),
92so deployment is one file:
93
94```sh
95go build -o lchat . # native
96GOOS=linux GOARCH=amd64 go build -o lchat . # cross-compile for a Linux box
97```
98
99See **[Deploy](#deploy)** for how to run it in production β€” a self-hosted router
100(the default), a single instance behind your own reverse proxy, Docker, or
101another platform.
102
103## Native iOS & macOS app
104
105There's a native SwiftUI client in [`ios/`](ios/README.md) that runs on
106iPhone/iPad and on macOS via **Mac Catalyst** (same target, same code, same
107end-to-end encryption contract). The `.xcodeproj` is generated by
108[`xcodegen`](https://github.com/yonaskolb/XcodeGen), not committed.
109
110You can build both variants from the command line **without launching Xcode**
111(handy for CI or a quick compile check) β€” `CODE_SIGNING_ALLOWED=NO` skips the
112need for a signing team:
113
114```sh
115cd ios
116xcodegen generate # writes LChat.xcodeproj from project.yml
117
118# iOS (compiles against the Simulator SDK β€” no device or signing needed):
119xcodebuild -project LChat.xcodeproj -scheme LChat \
120 -destination 'generic/platform=iOS Simulator' \
121 -derivedDataPath build CODE_SIGNING_ALLOWED=NO build
122
123# macOS (Mac Catalyst β€” a native build that doesn't touch the iOS Simulator):
124xcodebuild -project LChat.xcodeproj -scheme LChat \
125 -destination 'platform=macOS,variant=Mac Catalyst' \
126 -derivedDataPath build CODE_SIGNING_ALLOWED=NO build
127```
128
129`-derivedDataPath build` keeps the products inside the repo (under the
130gitignored `ios/build/`) instead of a hashed folder buried in
131`~/Library/Developer/Xcode/DerivedData/`. The built apps land at:
132
133- `ios/build/Build/Products/Debug-iphonesimulator/LChat.app`
134- `ios/build/Build/Products/Debug-maccatalyst/LChat.app` ← the runnable Mac app
135
136That Mac path is still a mouthful, so symlink it somewhere handy and launch it
137(the `LChat-*.app` symlink names are gitignored):
138
139```sh
140ln -sfn build/Build/Products/Debug-maccatalyst/LChat.app LChat-mac.app
141open LChat-mac.app # runs the Mac (Catalyst) build
142```
143
144A successful build ends in `** BUILD SUCCEEDED **`. If a Simulator destination
145can't be resolved (e.g. an out-of-date CoreSimulator), target the SDK directly
146instead: `-sdk iphonesimulator` in place of the `-destination` flag. See
147[`ios/README.md`](ios/README.md) for signing, running on a device, and how the
148client maps to the server.
149
150## Deploy
151
152lchat is a single static binary that keeps all its state in one SQLite file and
153speaks WebSockets on `/ws`. Deploying it anywhere is the same three things: run
154the binary, give it a **persistent path for the database** (`LCHAT_DB`), and put
155it behind **HTTPS** β€” required in production, since the invite token travels to
156the server and the fragment-key model assumes an encrypted transport. SQLite has
157a single writer, so run **one instance per database** (scale by adding instances,
158not replicas of one).
159
160### Self-hosted router (recommended)
161
162How we run it, and the default for hosting **one or many** chats: a single box
163runs lchat's own built-in TLS router, and each chat is a fully-isolated instance
164on its own subdomain, database, and admin token β€” automatic Let's Encrypt certs,
165no external reverse proxy, no container platform. One line installs the latest
166stable release (sha256-verified; amd64 or arm64) and bootstraps the box:
167
168```sh
169# on a fresh box (Ubuntu/Debian; inbound 80 + 443 open):
170curl -fsSL https://github.com/elevenmessenger/releases/releases/download/server/install.sh | sudo bash
171
172# add a chat β€” point its DNS A record at the box first; the TLS cert issues
173# automatically, and the printed setup link makes you the admin:
174lchat add-instance chat.example.com
175```
176
177Add as many chats as you like; each is isolated. The box then **keeps itself up
178to date** from the signed stable release channel β€” hourly checks, per-instance
179health check, automatic rollback β€” and each chat's admin picks a channel
180(stable / beta / edge) in their admin panel. The full guide (backups, bots,
181honest limitations) is **[docs/self-hosting.md](docs/self-hosting.md)**,
182published as the README of the public
183[releases repo](https://github.com/elevenmessenger/releases).
184
185Deploying your **own builds** instead (development, forks): build, stage, then
186roll out β€” zero-downtime, health-checked, auto-rollback; pin canaries so the
187auto-updater leaves them alone:
188
189```sh
190./deploy/build-linux.sh # builds + stamps a version id
191scp lchat-linux-amd64 root@box:/opt/lchat/incoming/lchat-upload
192ssh root@box /opt/lchat/lchat stage --file /opt/lchat/incoming/lchat-upload
193ssh root@box /opt/lchat/lchat deploy chat.example.com --version <ver> --pin # canary, held
194ssh root@box /opt/lchat/lchat deploy --all --version <ver> # the whole fleet
195ssh root@box /opt/lchat/lchat rollback chat.example.com # step back
196```
197
198The full design β€” routing, supervision (systemd), releases + auto-update,
199backups, and hardening β€” is in
200**[docs/instance-router.md](docs/instance-router.md)** and
201**[docs/server-releases.md](docs/server-releases.md)**.
202
203### A single instance behind your own reverse proxy
204
205The minimal path for one chat, if you'd rather run your own proxy: build, copy up,
206and terminate TLS with Caddy or nginx.
207
208```sh
209GOOS=linux GOARCH=amd64 go build -o lchat .
210scp lchat you@server:/srv/lchat/
211LCHAT_DB=/srv/lchat/lchat.db ADDR=:1747 ./lchat # via a systemd unit in practice
212```
213
214Then proxy `https://chat.example.com` β†’ `127.0.0.1:1747` (Caddy gets you TLS in
215two lines).
216
217### Docker
218
219The repo ships a `Dockerfile`: a fully static binary in a distroless image
220(~2MB of image around the binary, no shell), running as a **non-root** user.
221Runtime config is env vars:
222
223- `LCHAT_DB` β€” SQLite path; point it at the persistent volume (`/data/lchat.db`)
224- `LCHAT_ADMIN_TOKEN` β€” admin token
225- `ADDR` β€” listen address (defaults to `:1747`, matching the exposed port)
226
227> **Gotcha (learned the hard way):** the env var is `LCHAT_ADMIN_TOKEN`, *not*
228> `ADMIN_TOKEN`. Some container platforms auto-provision their own secret for a
229> bare `ADMIN_TOKEN` ("referenced in code") and inject that, silently overriding
230> whatever you set. The `LCHAT_` prefix dodges that.
231
232```sh
233# 1. Build, and generate a URL-safe admin token (it goes in a URL #fragment):
234docker build -t lchat .
235TOKEN=$(openssl rand -base64 32 | tr -d '\n' | tr '+/' '-_' | tr -d '=')
236
237# 2. Run with a named volume for the database:
238docker run -d --name lchat --restart unless-stopped \
239 -p 127.0.0.1:1747:1747 \
240 -v lchat-data:/data \
241 -e LCHAT_DB=/data/lchat.db -e "LCHAT_ADMIN_TOKEN=$TOKEN" \
242 lchat
243# Your admin URL is then: https://group.example.com/admin#token=$TOKEN
244```
245
246Then terminate TLS in front of it exactly as in the reverse-proxy section
247above (`https://group.example.com` β†’ `127.0.0.1:1747`; make sure the proxy
248passes the `/ws` WebSocket upgrade).
249
250The container runs as uid 65532, and `/data` in the image is owned by that
251user β€” a **named volume** (as above) inherits the ownership on first use. If
252you'd rather **bind-mount** a host directory, chown it first
253(`chown 65532:65532 /srv/lchat-data`). Keep the service at a **single
254replica** β€” SQLite has one writer.
255
256**Updating later (no data loss):** rebuild and recreate the container; the
257volume (messages, invites, push subscriptions, VAPID keys, group name)
258survives. Only deleting the *volume* wipes data.
259
260### Other platforms (Fly.io, Render, Railway, Heroku, …)
261
262Each of these should be trivial β€” it's the same single static binary plus a
263persistent volume β€” but **none is tested yet**, so treat the below as the recipe,
264not a proven path. Any host that runs a binary (or the shipped `Dockerfile`) with a
265persistent volume works the same way; the constraints are just the three from the
266top of this section:
267
268- Attach a **persistent volume** and set `LCHAT_DB` to a path on it (e.g.
269 `/data/lchat.db`); the database must survive restarts and redeploys.
270- Bind the port the platform gives you: `ADDR=:$PORT` (or the default `:1747`).
271- Let the platform terminate **TLS** β€” and make sure it proxies the `/ws`
272 WebSocket upgrade (all of these do).
273- Set a URL-safe `LCHAT_ADMIN_TOKEN`, and keep the service at **one instance**
274 (SQLite is single-writer β€” don't scale to multiple replicas).
275
276A tested, per-platform write-up (a `fly.toml`, a Render blueprint, an `app.json`)
277would be a welcome contribution.
278
279## Flow
280
2811. **Admin** opens the admin link (roster management only β€” no decryption key) and
282 creates invites. Each invite β†’ a secret link.
2832. **A guest** opens their link, picks a screen name, and joins β€” their browser
284 generates a keypair and registers its public key on the roster.
2853. Anyone can start a **DM** with another member or spin up a **group**; each
286 thread gets its own key, wrapped to its members. The instance is the trust
287 boundary (its roster), Γ  la a private Slack/iMessage.
288
289## Posting from a script (bots, cron, CI)
290
291You can post into a thread from outside the browser **without weakening the
292encryption**. The trick: the encryption stays on *your* side, never the server's.
293Each thread has its own AES key, wrapped to every member's keypair. So a script
294posts as a real member: give it that user's **device token** (authorises the API)
295and **private key** (unwraps the thread key), plus the **thread id**. The helper
296fetches the thread's wrapped key, unwraps it locally, encrypts the message, and
297hands the server only ciphertext β€” exactly like the browser does.
298
299```sh
300# text as an argument
301tools/lchat-post.mjs --host https://group.example.com \
302 --token "$TOKEN" --priv "$PRIV" --thread "$TID" "deploy finished βœ…"
303
304# or pipe it in, with a custom screen name (config via env)
305echo "build #42 passed" | tools/lchat-post.mjs --thread "$TID" --name CI
306```
307
308Config can come from flags or `LCHAT_*` env vars (`LCHAT_HOST` / `LCHAT_TOKEN` /
309`LCHAT_PRIV` / `LCHAT_THREAD`, and `LCHAT_NAME` for the screen name). The token
310and private key come from a joined session (or an add-device link). The helper
311reuses `web/static/crypto.js`, so its blobs are byte-for-byte what the browser
312produces and render as ordinary chat messages.
313
314Under the hood it `POST`s to `/api/messages?token=…` with
315`{"thread_id":"…","blob":"<ciphertext>"}`. The server checks the poster is a
316member of that thread, then stores, broadcasts, and pushes it just like a
317WebSocket `msg` frame β€” **it never sees the plaintext, the private key, or the
318thread key.** Keep those on the calling host, never on the server, so the server
319holds only ciphertext it can't open (the message-content guarantee above).
320
321## Accessibility
322
323Eleven Messenger commits to **[WCAG 2.2 Level AA](https://www.w3.org/TR/WCAG22/)**
324across the web surfaces, and to the same bar on iOS/Mac via VoiceOver, Dynamic
325Type, and Reduce Motion support. The full statement β€” what the commitment means
326for UI changes, and where we honestly are today β€” is
327**[docs/accessibility.md](docs/accessibility.md)**.
328
329## Tests
330
331```sh
332go test ./... # backend: DB logic, HTTP handlers (httptest), router control plane
333npm test # frontend logic in Node: crypto, ui/format, mini-apps, commands,
334 # invites, key backup, thread keys, messages
335```
336
337The Go tests cover the invite lifecycle, admin auth, messages, push subscriptions,
338the group name + HTTP handlers (auth gating, invite/messages flow, group-name
339injection + HTML-escaping), and the self-hosted router / versioned-deploy control
340plane. `npm test` runs the pure-logic frontend modules under Node β€” the E2EE
341round-trip in `crypto.js`, plus `ui.js`/`format.js`, mini-apps, slash-commands,
342invites, and key backup. Browser/DOM end-to-end (the chat UI, onboarding) isn't in
343CI yet β€” exercise it against a throwaway instance.
344
345## Layout
346
347```
348main.go HTTP routing, handlers, admin bootstrap, //go:embed
349db.go SQLite: config, invites, messages (ciphertext only)
350hub.go WebSocket fan-out hub + message ingest
351miniapps.go mini-apps: scheduled messages + per-thread encrypted state
352push.go Web Push (self-hosted VAPID); apns.go = central APNs relay client
353unfurl.go OpenGraph link previews (SSRF-guarded fetch + cache)
354bootstrap.go, control.go, registry.go, router.go, versions.go, bot.go
355 self-hosted router: provision a box, add-instance / deploy /
356 rollback, the route registry, TLS front door, versioned deploys
357web/pages/*.html chat / join / admin pages
358web/static/crypto.js Web Crypto (encrypt/decrypt JSON + bytes, key wrap/unwrap)
359web/static/shell.js chat page host: sidebar, thread list, WebSocket, keys, uploads
360web/static/apps/*.js mini-app framework + apps, including the chat engine (chat.js)
361web/static/*.js also join, admin, and pure ui/format helpers (unit-tested)
362web/static/style.css styles
363tools/lchat-post.mjs CLI to post an encrypted message from a script (see above)
364docs/*.md design notes (router, threads/DMs, push relay, mini-apps, …)
365```
366
367## Licence
368
369[The Mozilla Public License, Version 2.0](LICENSE) β€” an OSI-approved,
370file-level ("weak") copyleft licence: modifications to this code stay open
371under the same terms, while a separate work that merely depends on it isn't
372dragged open with it.
373
374This is an interim choice, not a final one. In the spirit of
375[the Eleven Factors](https://11factor.org), the project's longer-term goal
376is a purpose-drafted licence β€” provisionally named GAIEST, the *Generative
377Artificial Intelligence Evolving Software Technology Licence* β€” that also
378closes the copyleft gap for hosted/SaaS use and carries a real disclosure
379requirement for AI-written material, once that draft has been through
380proper legal review and an OSI submission. Until then, AI-authorship
381marking is a **project convention**, not a licence condition β€” see
382[docs/ai-markers.md](docs/ai-markers.md).
383