# 🤖 Eleven Messenger

A small, self-hostable messenger — DMs and group chats, no logins, no passwords,
just secret invite links. Messages are **end-to-end encrypted**: the server only
ever sees ciphertext, so a stolen database or server snapshot exposes no message
content. (It does see metadata — screen names and who shares a thread with whom;
see Trade-offs.)

> **`lchat`** is the engine — the Go binary + CLI, and the name used throughout
> this repo. **Eleven Messenger** is the product built on it (the web client plus
> the native iOS/macOS apps).

## How the encryption works

- Each person has their own **keypair** (ECDH P-256), generated in the browser on
  first join. The public key lives in the roster; the private key never leaves the
  device (it ships, encrypted, only via an "add a device" link).
- Every **thread** — a DM (2 people) or a group (N) — has its own **AES-GCM key**,
  wrapped to each member's public key (ECIES). Only members can unwrap it.
- Content shared with everyone (e.g. profile photos) uses one **per-instance key**,
  wrapped to each member the same way — so even that reaches the server only as a
  blob it can't open.
- The browser encrypts every message (including the sender's screen name) before
  sending, and decrypts on receipt. The server stores and relays only opaque blobs
  plus key material it can't open.
- An invite link carries a one-time secret in its `#fragment` (e.g.
  `/join/<name>#<secret>`) used to bootstrap the joiner's keys — never sent to the
  server. The **admin token authorises roster management only; it is not a
  decryption key.** Even a full copy of the database reveals no message content.

**Trade-offs (inherent to this model):**

- Membership is the boundary, enforced cryptographically: to read a thread you
  must be one of its members (hold its wrapped key).
- Removing a member stops the server delivering new messages to them, but doesn't
  claw back a key they already hold (no forward secrecy on leave).
- If a member loses their device with no backup/add-device link, their key
  material — and the threads only they could decrypt — is gone.
- **Metadata is not encrypted.** The server can't read message content, but the
  roster stores each member's screen name in clear (separate from the encrypted
  copy carried inside each message), and the server necessarily knows who shares
  a thread with whom in order to route messages. So a server compromise reveals
  the social graph and names — just not what anyone said.
- **Unsending leaves a dated marker.** Deleting a message really deletes the
  ciphertext, but the server keeps a small tombstone row — which thread, which
  message id, when — so members' devices that were offline at the time still
  learn to drop their local copy. The tombstone holds no content and no key
  material, but it is a durable record *that* a deletion happened (the server
  transiently knew that anyway — it performed the delete).
- **Key distribution trusts the server.** Public keys are handed out by the
  server's roster and aren't yet verified out of band (no safety-number check).
  A *passive* compromise — a stolen database — can't use that (the content
  guarantee above holds), but an *active* malicious server could substitute a
  public key to intercept *new* conversations. Out-of-band key verification is
  planned to close this.
- **A bot is a member whose "device" is a machine you choose.** Adding a bot
  (see `agent/`) to a thread hands that thread's key to the machine the bot
  runs on — so whoever controls that machine holds the key to those
  conversations, exactly as if they were a member. Threads without a bot are
  untouched. Crucially, the bot doesn't have to live on the chat server: it's a
  pure outbound client (it dials the instance and the model API; nothing ever
  dials it — no open ports), so you can run it on any machine you already
  trust — a home server behind your router, or a box on a private network
  whose only way out is to your instance and the model API. Run it there and a
  fully compromised chat server still yields nothing: the keys are on a
  machine an attacker can't even reach.

## Run it

```sh
go run .                 # listens on :1747, creates lchat.db
```

On first start it prints an **admin link** containing your admin token —
open it once and keep it secret:

```
Admin link (open once, keep it secret):
http://localhost:1747/admin#token=...
```

Flags / env:

- `-addr :1747` — listen address
- `-db lchat.db` — database file path
- `-reset-admin` — generate and print a fresh admin token
- `LCHAT_ADMIN_TOKEN=...` — set a fixed admin token instead of a generated one

## Build a single binary

The frontend and the SQLite engine are compiled in (pure-Go SQLite, no cgo),
so deployment is one file:

```sh
go build -o lchat .          # native
GOOS=linux GOARCH=amd64 go build -o lchat .   # cross-compile for a Linux box
```

See **[Deploy](#deploy)** for how to run it in production — a self-hosted router
(the default), a single instance behind your own reverse proxy, Docker, or
another platform.

## Native iOS & macOS app

There's a native SwiftUI client in [`ios/`](ios/README.md) that runs on
iPhone/iPad and on macOS via **Mac Catalyst** (same target, same code, same
end-to-end encryption contract). The `.xcodeproj` is generated by
[`xcodegen`](https://github.com/yonaskolb/XcodeGen), not committed.

You can build both variants from the command line **without launching Xcode**
(handy for CI or a quick compile check) — `CODE_SIGNING_ALLOWED=NO` skips the
need for a signing team:

```sh
cd ios
xcodegen generate        # writes LChat.xcodeproj from project.yml

# iOS (compiles against the Simulator SDK — no device or signing needed):
xcodebuild -project LChat.xcodeproj -scheme LChat \
  -destination 'generic/platform=iOS Simulator' \
  -derivedDataPath build CODE_SIGNING_ALLOWED=NO build

# macOS (Mac Catalyst — a native build that doesn't touch the iOS Simulator):
xcodebuild -project LChat.xcodeproj -scheme LChat \
  -destination 'platform=macOS,variant=Mac Catalyst' \
  -derivedDataPath build CODE_SIGNING_ALLOWED=NO build
```

`-derivedDataPath build` keeps the products inside the repo (under the
gitignored `ios/build/`) instead of a hashed folder buried in
`~/Library/Developer/Xcode/DerivedData/`. The built apps land at:

- `ios/build/Build/Products/Debug-iphonesimulator/LChat.app`
- `ios/build/Build/Products/Debug-maccatalyst/LChat.app` ← the runnable Mac app

That Mac path is still a mouthful, so symlink it somewhere handy and launch it
(the `LChat-*.app` symlink names are gitignored):

```sh
ln -sfn build/Build/Products/Debug-maccatalyst/LChat.app LChat-mac.app
open LChat-mac.app        # runs the Mac (Catalyst) build
```

A successful build ends in `** BUILD SUCCEEDED **`. If a Simulator destination
can't be resolved (e.g. an out-of-date CoreSimulator), target the SDK directly
instead: `-sdk iphonesimulator` in place of the `-destination` flag. See
[`ios/README.md`](ios/README.md) for signing, running on a device, and how the
client maps to the server.

## Deploy

lchat is a single static binary that keeps all its state in one SQLite file and
speaks WebSockets on `/ws`. Deploying it anywhere is the same three things: run
the binary, give it a **persistent path for the database** (`LCHAT_DB`), and put
it behind **HTTPS** — required in production, since the invite token travels to
the server and the fragment-key model assumes an encrypted transport. SQLite has
a single writer, so run **one instance per database** (scale by adding instances,
not replicas of one).

### Self-hosted router (recommended)

How we run it, and the default for hosting **one or many** chats: a single box
runs lchat's own built-in TLS router, and each chat is a fully-isolated instance
on its own subdomain, database, and admin token — automatic Let's Encrypt certs,
no external reverse proxy, no container platform. One line installs the latest
stable release (sha256-verified; amd64 or arm64) and bootstraps the box:

```sh
# on a fresh box (Ubuntu/Debian; inbound 80 + 443 open):
curl -fsSL https://github.com/elevenmessenger/releases/releases/download/server/install.sh | sudo bash

# add a chat — point its DNS A record at the box first; the TLS cert issues
# automatically, and the printed setup link makes you the admin:
lchat add-instance chat.example.com
```

Add as many chats as you like; each is isolated. The box then **keeps itself up
to date** from the signed stable release channel — hourly checks, per-instance
health check, automatic rollback — and each chat's admin picks a channel
(stable / beta / edge) in their admin panel. The full guide (backups, bots,
honest limitations) is **[docs/self-hosting.md](docs/self-hosting.md)**,
published as the README of the public
[releases repo](https://github.com/elevenmessenger/releases).

Deploying your **own builds** instead (development, forks): build, stage, then
roll out — zero-downtime, health-checked, auto-rollback; pin canaries so the
auto-updater leaves them alone:

```sh
./deploy/build-linux.sh                                       # builds + stamps a version id
scp lchat-linux-amd64 root@box:/opt/lchat/incoming/lchat-upload
ssh root@box /opt/lchat/lchat stage  --file /opt/lchat/incoming/lchat-upload
ssh root@box /opt/lchat/lchat deploy chat.example.com --version <ver> --pin   # canary, held
ssh root@box /opt/lchat/lchat deploy --all --version <ver>                    # the whole fleet
ssh root@box /opt/lchat/lchat rollback chat.example.com                       # step back
```

The full design — routing, supervision (systemd), releases + auto-update,
backups, and hardening — is in
**[docs/instance-router.md](docs/instance-router.md)** and
**[docs/server-releases.md](docs/server-releases.md)**.

### A single instance behind your own reverse proxy

The minimal path for one chat, if you'd rather run your own proxy: build, copy up,
and terminate TLS with Caddy or nginx.

```sh
GOOS=linux GOARCH=amd64 go build -o lchat .
scp lchat you@server:/srv/lchat/
LCHAT_DB=/srv/lchat/lchat.db ADDR=:1747 ./lchat     # via a systemd unit in practice
```

Then proxy `https://chat.example.com` → `127.0.0.1:1747` (Caddy gets you TLS in
two lines).

### Docker

The repo ships a `Dockerfile`: a fully static binary in a distroless image
(~2MB of image around the binary, no shell), running as a **non-root** user.
Runtime config is env vars:

- `LCHAT_DB` — SQLite path; point it at the persistent volume (`/data/lchat.db`)
- `LCHAT_ADMIN_TOKEN` — admin token
- `ADDR` — listen address (defaults to `:1747`, matching the exposed port)

> **Gotcha (learned the hard way):** the env var is `LCHAT_ADMIN_TOKEN`, *not*
> `ADMIN_TOKEN`. Some container platforms auto-provision their own secret for a
> bare `ADMIN_TOKEN` ("referenced in code") and inject that, silently overriding
> whatever you set. The `LCHAT_` prefix dodges that.

```sh
# 1. Build, and generate a URL-safe admin token (it goes in a URL #fragment):
docker build -t lchat .
TOKEN=$(openssl rand -base64 32 | tr -d '\n' | tr '+/' '-_' | tr -d '=')

# 2. Run with a named volume for the database:
docker run -d --name lchat --restart unless-stopped \
  -p 127.0.0.1:1747:1747 \
  -v lchat-data:/data \
  -e LCHAT_DB=/data/lchat.db -e "LCHAT_ADMIN_TOKEN=$TOKEN" \
  lchat
# Your admin URL is then: https://group.example.com/admin#token=$TOKEN
```

Then terminate TLS in front of it exactly as in the reverse-proxy section
above (`https://group.example.com` → `127.0.0.1:1747`; make sure the proxy
passes the `/ws` WebSocket upgrade).

The container runs as uid 65532, and `/data` in the image is owned by that
user — a **named volume** (as above) inherits the ownership on first use. If
you'd rather **bind-mount** a host directory, chown it first
(`chown 65532:65532 /srv/lchat-data`). Keep the service at a **single
replica** — SQLite has one writer.

**Updating later (no data loss):** rebuild and recreate the container; the
volume (messages, invites, push subscriptions, VAPID keys, group name)
survives. Only deleting the *volume* wipes data.

### Other platforms (Fly.io, Render, Railway, Heroku, …)

Each of these should be trivial — it's the same single static binary plus a
persistent volume — but **none is tested yet**, so treat the below as the recipe,
not a proven path. Any host that runs a binary (or the shipped `Dockerfile`) with a
persistent volume works the same way; the constraints are just the three from the
top of this section:

- Attach a **persistent volume** and set `LCHAT_DB` to a path on it (e.g.
  `/data/lchat.db`); the database must survive restarts and redeploys.
- Bind the port the platform gives you: `ADDR=:$PORT` (or the default `:1747`).
- Let the platform terminate **TLS** — and make sure it proxies the `/ws`
  WebSocket upgrade (all of these do).
- Set a URL-safe `LCHAT_ADMIN_TOKEN`, and keep the service at **one instance**
  (SQLite is single-writer — don't scale to multiple replicas).

A tested, per-platform write-up (a `fly.toml`, a Render blueprint, an `app.json`)
would be a welcome contribution.

## Flow

1. **Admin** opens the admin link (roster management only — no decryption key) and
   creates invites. Each invite → a secret link.
2. **A guest** opens their link, picks a screen name, and joins — their browser
   generates a keypair and registers its public key on the roster.
3. Anyone can start a **DM** with another member or spin up a **group**; each
   thread gets its own key, wrapped to its members. The instance is the trust
   boundary (its roster), à la a private Slack/iMessage.

## Posting from a script (bots, cron, CI)

You can post into a thread from outside the browser **without weakening the
encryption**. The trick: the encryption stays on *your* side, never the server's.
Each thread has its own AES key, wrapped to every member's keypair. So a script
posts as a real member: give it that user's **device token** (authorises the API)
and **private key** (unwraps the thread key), plus the **thread id**. The helper
fetches the thread's wrapped key, unwraps it locally, encrypts the message, and
hands the server only ciphertext — exactly like the browser does.

```sh
# text as an argument
tools/lchat-post.mjs --host https://group.example.com \
  --token "$TOKEN" --priv "$PRIV" --thread "$TID" "deploy finished ✅"

# or pipe it in, with a custom screen name (config via env)
echo "build #42 passed" | tools/lchat-post.mjs --thread "$TID" --name CI
```

Config can come from flags or `LCHAT_*` env vars (`LCHAT_HOST` / `LCHAT_TOKEN` /
`LCHAT_PRIV` / `LCHAT_THREAD`, and `LCHAT_NAME` for the screen name). The token
and private key come from a joined session (or an add-device link). The helper
reuses `web/static/crypto.js`, so its blobs are byte-for-byte what the browser
produces and render as ordinary chat messages.

Under the hood it `POST`s to `/api/messages?token=…` with
`{"thread_id":"…","blob":"<ciphertext>"}`. The server checks the poster is a
member of that thread, then stores, broadcasts, and pushes it just like a
WebSocket `msg` frame — **it never sees the plaintext, the private key, or the
thread key.** Keep those on the calling host, never on the server, so the server
holds only ciphertext it can't open (the message-content guarantee above).

## Accessibility

Eleven Messenger commits to **[WCAG 2.2 Level AA](https://www.w3.org/TR/WCAG22/)**
across the web surfaces, and to the same bar on iOS/Mac via VoiceOver, Dynamic
Type, and Reduce Motion support. The full statement — what the commitment means
for UI changes, and where we honestly are today — is
**[docs/accessibility.md](docs/accessibility.md)**.

## Tests

```sh
go test ./...     # backend: DB logic, HTTP handlers (httptest), router control plane
npm test          # frontend logic in Node: crypto, ui/format, mini-apps, commands,
                  # invites, key backup, thread keys, messages
```

The Go tests cover the invite lifecycle, admin auth, messages, push subscriptions,
the group name + HTTP handlers (auth gating, invite/messages flow, group-name
injection + HTML-escaping), and the self-hosted router / versioned-deploy control
plane. `npm test` runs the pure-logic frontend modules under Node — the E2EE
round-trip in `crypto.js`, plus `ui.js`/`format.js`, mini-apps, slash-commands,
invites, and key backup. Browser/DOM end-to-end (the chat UI, onboarding) isn't in
CI yet — exercise it against a throwaway instance.

## Layout

```
main.go            HTTP routing, handlers, admin bootstrap, //go:embed
db.go              SQLite: config, invites, messages (ciphertext only)
hub.go             WebSocket fan-out hub + message ingest
miniapps.go        mini-apps: scheduled messages + per-thread encrypted state
push.go            Web Push (self-hosted VAPID); apns.go = central APNs relay client
unfurl.go          OpenGraph link previews (SSRF-guarded fetch + cache)
bootstrap.go, control.go, registry.go, router.go, versions.go, bot.go
                   self-hosted router: provision a box, add-instance / deploy /
                   rollback, the route registry, TLS front door, versioned deploys
web/pages/*.html       chat / join / admin pages
web/static/crypto.js   Web Crypto (encrypt/decrypt JSON + bytes, key wrap/unwrap)
web/static/shell.js    chat page host: sidebar, thread list, WebSocket, keys, uploads
web/static/apps/*.js   mini-app framework + apps, including the chat engine (chat.js)
web/static/*.js        also join, admin, and pure ui/format helpers (unit-tested)
web/static/style.css   styles
tools/lchat-post.mjs   CLI to post an encrypted message from a script (see above)
docs/*.md              design notes (router, threads/DMs, push relay, mini-apps, …)
```

## Licence

[The Mozilla Public License, Version 2.0](LICENSE) — an OSI-approved,
file-level ("weak") copyleft licence: modifications to this code stay open
under the same terms, while a separate work that merely depends on it isn't
dragged open with it.

This is an interim choice, not a final one. In the spirit of
[the Eleven Factors](https://11factor.org), the project's longer-term goal
is a purpose-drafted licence — provisionally named GAIEST, the *Generative
Artificial Intelligence Evolving Software Technology Licence* — that also
closes the copyleft gap for hosted/SaaS use and carries a real disclosure
requirement for AI-written material, once that draft has been through
proper legal review and an OSI submission. Until then, AI-authorship
marking is a **project convention**, not a licence condition — see
[docs/ai-markers.md](docs/ai-markers.md).
