eleven / messenger Public
π€ Threads & DMs β multiple conversations per instance
Status: in progress (branch threads-and-dms, testing on test). This builds on docs/invite-redesign.md (users, device tokens, server-blind key delivery).
Today one instance = one group chat with a single group key. This adds multiple threads β DMs, groups, and a personal "self" thread β within one instance, without weakening the E2EE invariant: the server still only ever stores ciphertext and key-blobs it cannot open.
Model
- Instance = a trust boundary: a roster of people + an admin + a subdomain. A new trust circle (e.g. a kid's friends) is a separate instance, not a thread here. Cross-instance contact is intentionally impossible. The instance name is the app/Home-Screen name.
- Thread = one conversation. The same primitive at every size: - 1 member β "self" (notes), header shows "Notes". - 2 members β a DM, header shows the other person's name. - N members β a group, header shows the group's (encrypted) name. DM-vs-group is a UI distinction, not a different mechanism.
- Roster = instance-level directory of
{user_id, label, public_key}. Open (high trust): every member can see it and start threads with anyone β no introductions. The member list lives here, not inside an "everyone" group (we don't auto-create one).
Crypto
- User keypair (ECDH P-256), one per person. Public key lives in the roster; private key lives in the browser session and is shippable (pkcs8) so it can ride an add-device link. Chosen over device keypairs for simpler multi-device onboarding; lchat already ships key material via the invite fragment, so user keys add no new risk class. (Trade-off: lost device β rotate the user key + affected thread keys, rather than dropping one device.)
- Thread key β a per-thread AES-GCM-256 key. Messages, attachments, and the thread's name are encrypted with it, exactly as the single group key works today.
- Wrapping β a thread key is delivered to a member by wrapping it to their user public key (ECIES: ephemeral ECDH β HKDF-SHA-256 β AES-GCM). The server stores one wrapped blob per (thread, member) and cannot unwrap any of them.
crypto.js:generateUserKeypair,wrapThreadKey,unwrapThreadKey. - No instance-wide key. Each thread has its own key; nobody (not even the admin) holds a key that reads everything. The admin token is purely roster management β it is not, and never was, a decryption key. The admin reads only threads they're a member of.
What the server sees (accepted metadata posture)
Routing requires the server to know the social graph: thread membership, who sent each message, message timing/size, and roster display names. It never sees message content, attachment bytes, or thread/group names. Hiding the graph too (sealed-sender / private membership) is explicitly out of scope.
Data model (additive migrations)
users(id, label, created_at, public_key)βpublic_keyadded; registered on first join. (Roster = this table.)threads(id, kind, created_at, name_blob)βkindβ self|dm|group;name_blobis the client-encrypted group name (null for dm/self).thread_members(thread_id, user_id, created_at)β membership (the graph).thread_keys(thread_id, user_id, wrapped)β the thread key wrapped to each member's user public key. One row per member; server-blind.messagesgainsthread_id; reactions key off(thread_id, message_id, β¦).dm_pairs(user_a, user_b, thread_id)β canonical dedupe so there's exactly one DM per pair (user_a < user_b).
Key flows
- Register pubkey β on first load with a session, the browser generates the user keypair (if absent), stores the private key in the session, and
PUTs the public key to the roster. - Start a DM β pick someone from the roster; if no
dm_pairsrow exists, generate a thread key, wrap it to both user pubkeys, create the thread + members + dm_pair. Otherwise open the existing one. - Create a group β name it, pick members; generate a thread key, wrap it to each member's pubkey, store name_blob. Creator is owner.
- Add a member (existing instance member) β owner/any member fetches the invitee's roster pubkey, wraps the current thread key to it, adds the row.
- Send / receive β WS carries
{thread_id, blob}; the server fans out only to that thread's members and gates reads by membership. Client decrypts with the thread key it unwrapped (and caches). - Onboarding (new person β instance) β admin-only. The invite, as today, delivers the user keypair (generated by the inviter or the joiner) and seeds a DM with the inviter (its thread key wrapped into the invite). The joiner lands with one working conversation and a roster.
- Add a device (same person) β the existing device bundles the user private key into the add-device link (under
HKDF(T)); the new device unwraps it and can then open every thread the user belongs to (their wrapped blobs are on the server). No per-thread re-wrap needed β the win of user keypairs.
Deferred (designed, not in v1)
- Remove a member / rekey β admin removes from the instance, or owner from a group: rotate the thread key, re-wrap to the remaining members, so the removed party can't read future messages (past messages they already saw). Needs a UI + a rotation endpoint.
- Group join links (instance-members-only, device-token-gated), mute, restricted discovery modes, production migration of the 5 live instances.
UI
- Desktop: master-detail β thread list (left), open thread (right). The list shows DMs by counterpart name, groups by name, newest activity first.
- Mobile: single pane that navigates list β thread β back (and a "people" view for starting a new chat from the roster).
- Thread header: DM = the other person's name; group = its name; self = "Notes". The instance name stays the app/title-bar name.
- A "new chat" action opens the roster picker (DM = one person, group = several + a name).
Capabilities (default β safe-for-kids)
| Action | Who | |---|---| | Create group / start DM / add existing instance members | any member | | Rename a group / remove a group member | the group's owner | | Add a new person to the instance (roster) | admin only | | Remove a person from the instance (+ rekey) | admin only |
The admin guards the trust boundary; members move freely inside it. Restricted discovery (e.g. kids can only DM parents) is a later opt-in; default is open.
Migration (live instances β later)
The existing single chat becomes one group thread keyed with the current group key: create a threads row, attach all current members, wrap the existing group key to each member's pubkey (their browser does this on first load, since it already holds the group key from its session). Legacy #k= links and device tokens keep working. History is preserved in that thread. Built and run at merge time, after the model is proven on a fresh test.