docs: scope the "server compromise" claim to message content (#100)
The README claimed a server compromise "reveals nothing." That's now accurate for a passive/stolen-database attacker's access to message *content* (strengthened by the token-hashing and invite-claim fixes), but it overstated two structural realities: the server sees metadata (screen names + the membership graph, which it needs in order to route), and public keys are distributed by the server with no out-of-band verification, so an *active* malicious server could substitute a key and MITM new conversations. Scope the headline and CLI-section wording to "no message content" and add two Trade-offs bullets (unencrypted metadata; server-trusted key distribution). Considered but rejected: encrypting roster labels under the instance key — it would blind the admin console (which holds no member key by design) and wouldn't hide the membership graph anyway (the server routes by it).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+17
−5
README.md+17 −5
diff --git a/README.md b/README.md| index f6e27c2..5941fde 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -2,7 +2,9 @@ |
| 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. |
| +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.) |
| ## How the encryption works |
| @@ -17,8 +19,7 @@ sees ciphertext, so compromising the server reveals nothing. |
| - 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. |
| + decryption key.** Even a full copy of the database reveals no message content. |
| **Trade-offs (inherent to this model):** |
| @@ -28,6 +29,17 @@ sees ciphertext, so compromising the server reveals nothing. |
| 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. |
| +- **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. |
| ## Run it |
| @@ -211,8 +223,8 @@ 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, and the |
| -"compromise the server, get nothing" guarantee holds. |
| +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). |
| ## Tests |