eleven / messenger Public

docs: refresh README + CLAUDE.md for the multi-thread keypair model

Both still described the old single-group world (one shared AES key, /join#k=…,
admin holds the key, 'one feed'). Update to: per-user keypairs, per-thread keys
wrapped to members (ECIES), the instance key, /join/<name>#<secret> bootstrap, and
the admin token as roster-only (not a decryption key). DMs + groups as threads.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell da94774bd65768359740bc2f4a8c20ab8e402c38 parent 2aa29c2
2 files changed, +43 −28
  • CLAUDE.md +14 −7
  • README.md +29 −21
diff --git a/CLAUDE.md b/CLAUDE.md
index 4148ec0..cf0e9de 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,13 +9,20 @@ build and the decisions we've committed to.
The whole point of lchat is **"if the server is compromised, the attacker gets
nothing."** Every change must preserve that:
-- The server only ever stores/relays **opaque ciphertext**. It must never see
- plaintext message content, screen names, or file bytes, and must never receive
- or store the group key.
-- The shared **AES-GCM key lives in the URL `#fragment`** (`/join/<token>#k=…`
- and `/admin#token=…&k=…`). Fragments are never sent to the server. All
- encryption/decryption happens in the browser (`web/static/crypto.js`).
-- The **admin token** authorises invite management only — it is *not* a
+- The server only ever stores/relays **opaque ciphertext** plus key material it
+ cannot open. It must never see plaintext message content, screen names, or file
+ bytes, and must never receive any usable key — not a thread key, the instance
+ key, or a user's private key.
+- Each user has a **keypair** (ECDH P-256); the private key lives only in the
+ browser. Every **thread** has its own **AES-GCM key**, wrapped to each member's
+ public key via ECIES; the server stores only the wrapped blobs. There's also one
+ per-instance "instance key" (for all-members content like profile photos),
+ likewise wrapped per member. All wrap/unwrap + encrypt/decrypt is in the browser
+ (`web/static/crypto.js`).
+- Invite links carry a one-time secret in the URL `#fragment` (`/join/<name>#<T>`)
+ used only to bootstrap/ship the joiner's keys; fragments are never sent to the
+ server.
+- The **admin token** authorises roster management only — it is *not* a
decryption key. Store only its hash; never log tokens or keys.
- When you add a feature that handles user content, encrypt it client-side and
store a blob. Example: attachments upload `encryptBytes` output; the filename
diff --git a/README.md b/README.md
index 5331a64..f6e27c2 100644
--- a/README.md
+++ b/README.md
@@ -1,28 +1,33 @@
# lchat
-A tiny, kid-friendly group chat. One shared feed, no logins, no passwords —
-just secret invite links. Messages are **end-to-end encrypted**: the server
-only ever sees ciphertext, so compromising the server reveals nothing.
+A tiny, kid-friendly chat — 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 compromising the server reveals nothing.
## How the encryption works
-- There is one shared **AES-GCM key** for the chat.
-- The key lives in the `#fragment` of each invite link (e.g.
- `/join/<token>#k=<key>`). Browsers never send the fragment to the server.
-- 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.
-- The admin token authorises invite management only. Even with it — or with the
- whole database and server — an attacker cannot read messages without a link.
+- 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.
+- 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 with the whole database and server, an attacker can't read
+ messages.
**Trade-offs (inherent to this model):**
-- Anyone who gets a link can read the whole feed — the link *is* the key.
-- If the key is lost everywhere (all browsers cleared, no backup), the history
- is permanently unreadable. The admin page shows the key with a **Copy key**
- button — back it up.
-- Revoking an invite cuts that link's access to the *server* (no new messages,
- can't post). It can't claw back the key it already carried.
+- 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.
## Run it
@@ -169,10 +174,13 @@ https://group.luan.chat/admin#token=<ADMIN_TOKEN>
## Flow
-1. **Admin** opens the admin link, gets the chat's encryption key (generated in
- the browser), and creates invites. Each invite → a secret link.
-2. **A guest** opens their link, picks a screen name, and joins.
-3. Everyone shares **one feed**, like an iMessage group chat.
+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)