eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

πŸ€– Invite redesign β€” short links, server-blind key delivery

Status: Phase 1 (link + QR). Phase 2 (spoken word-code via PAKE) is parked.

Goal

Short, single-use, revocable invites with multi-device support and admin re-issue β€” without the server ever storing anything that could recover the group key. Same hard guarantee as today ("compromise the server, get nothing"), but nicer onboarding and manageable invites.

Crypto (load-bearing)

  • Invite secret T = 128-bit random β†’ ~22 base64url chars, carried in the URL fragment: …/join/<label>#<T>. The fragment is never sent to the server.
  • The client derives two values from T (HKDF-SHA256, domain-separated): - id = HKDF(T, "lchat-invite-id") β€” the server's lookup handle. - wrapKey = HKDF(T, "lchat-invite-wrap") β€” an AES-GCM key.
  • Minting (admin's browser, which holds the group key via the master link): wrapped = AES-GCM(groupKeyRaw, wrapKey). The browser POSTs only {id, wrapped}.
  • The server stores {id, wrapped} and never sees T β†’ it can't unwrap. A stolen DB yields {hash-like id, ciphertext}; recovering the key needs the 128-bit T, which never left the client.

The path segment (<label>) is cosmetic (server-visible, guessable) β€” all entropy lives in T.

Data model (additive β€” old invites/tokens keep working)

  • users(id, label, created_at) β€” a person.
  • key_invites(id, user_id, wrapped, created_at, expires_at, used_at) β€” server-blind wrapped key per invite; id is the HKDF handle.
  • device_tokens(token_hash, user_id, label, created_at) β€” ongoing transport credential (hashed, like the admin token), one per device.

Messages remain opaque blobs; author_hash (already present from message editing) keeps working per device. Full "messages attributed to users" UI is out of Phase 1 scope β€” the model just makes it possible later.

Flows

Mint (admin): browser generates T, derives id/wrapKey, wraps the group key β†’ POST /api/admin/invites {label, id, wrapped, expires}. Server creates the user (if new) + key_invite. Browser shows the link …/join/<label>#<T> + a QR.

Join (new device):

  1. Open …/join/<label>#<T> β†’ T from the fragment.
  2. GET /api/invite/<id> β†’ wrapped; unwrap with wrapKey β†’ group key (stored locally, same session shape as today: {token, key, name}).
  3. POST /api/invite/<id>/claim β†’ server marks it used (single-use) and issues a device token. Ongoing auth (messages/WebSocket/push) uses that token; T is one-time and never sent.

Add another device / re-issue a lost one: mint a fresh invite (new T, new wrapped blob) from any device that holds the key — admin, or your own existing device. QR is the clean desktop→mobile path.

Transport auth (additive)

tokenOK(token) accepts either a current non-revoked invite token (old model) or a valid device token (new model). So existing members are unaffected and new joiners use device tokens.

Migration

Fully additive β€” no data loss, nothing forced:

  • Old #k=<key> links and existing tokens keep working.
  • The existing group key is reused (the admin's browser wraps it) β€” no key regeneration, no re-onboarding.
  • Old-style links age out as people move to new invites.

The join page handles both: fragment k=<key> β†’ old flow; raw <T> β†’ new flow.

Phase 2 (parked): spoken word-code

Four-ish words via PAKE/SAS (Magic-Wormhole style): the server relays an ephemeral key exchange it can't read; the words authenticate the live exchange against MITM; the key goes device-to-device. Low entropy is cryptographically safe in that construction. Documented now, built later.