| 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) |
| |