eleven / messenger Public
π€ 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 seesTβ it can't unwrap. A stolen DB yields{hash-like id, ciphertext}; recovering the key needs the 128-bitT, 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;idis 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):
- Open
β¦/join/<label>#<T>βTfrom the fragment. GET /api/invite/<id>βwrapped; unwrap withwrapKeyβ group key (stored locally, same session shape as today:{token, key, name}).POST /api/invite/<id>/claimβ server marks it used (single-use) and issues a device token. Ongoing auth (messages/WebSocket/push) uses that token;Tis 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.