| 1 | # π€ Threads & DMs β multiple conversations per instance |
| 2 | |
| 3 | Status: **in progress** (branch `threads-and-dms`, testing on `test`). This builds |
| 4 | on `docs/invite-redesign.md` (users, device tokens, server-blind key delivery). |
| 5 | |
| 6 | Today one instance = one group chat with a single group key. This adds **multiple |
| 7 | threads** β DMs, groups, and a personal "self" thread β within one instance, |
| 8 | without weakening the E2EE invariant: **the server still only ever stores |
| 9 | ciphertext and key-blobs it cannot open.** |
| 10 | |
| 11 | ## Model |
| 12 | |
| 13 | - **Instance** = a trust boundary: a roster of people + an admin + a subdomain. |
| 14 | A new trust circle (e.g. a kid's friends) is a *separate instance*, not a |
| 15 | thread here. Cross-instance contact is intentionally impossible. The instance |
| 16 | name is the app/Home-Screen name. |
| 17 | - **Thread** = one conversation. The same primitive at every size: |
| 18 | - **1 member** β "self" (notes), header shows "Notes". |
| 19 | - **2 members** β a DM, header shows **the other person's name**. |
| 20 | - **N members** β a group, header shows the group's (encrypted) name. |
| 21 | DM-vs-group is a UI distinction, not a different mechanism. |
| 22 | - **Roster** = instance-level directory of `{user_id, label, public_key}`. Open |
| 23 | (high trust): every member can see it and start threads with anyone β no |
| 24 | introductions. The member list lives here, **not** inside an "everyone" group |
| 25 | (we don't auto-create one). |
| 26 | |
| 27 | ## Crypto |
| 28 | |
| 29 | - **User keypair** (ECDH P-256), one per person. Public key lives in the roster; |
| 30 | private key lives in the browser session and is shippable (pkcs8) so it can |
| 31 | ride an add-device link. Chosen over device keypairs for simpler multi-device |
| 32 | onboarding; lchat already ships key material via the invite fragment, so user |
| 33 | keys add no new risk class. (Trade-off: lost device β rotate the user key + |
| 34 | affected thread keys, rather than dropping one device.) |
| 35 | - **Thread key** β a per-thread AES-GCM-256 key. Messages, attachments, and the |
| 36 | thread's *name* are encrypted with it, exactly as the single group key works |
| 37 | today. |
| 38 | - **Wrapping** β a thread key is delivered to a member by wrapping it to their |
| 39 | user public key (ECIES: ephemeral ECDH β HKDF-SHA-256 β AES-GCM). The server |
| 40 | stores one wrapped blob per (thread, member) and **cannot unwrap any of them**. |
| 41 | `crypto.js`: `generateUserKeypair`, `wrapThreadKey`, `unwrapThreadKey`. |
| 42 | - **No instance-wide key.** Each thread has its own key; nobody (not even the |
| 43 | admin) holds a key that reads everything. The **admin token is purely roster |
| 44 | management** β it is not, and never was, a decryption key. The admin reads only |
| 45 | threads they're a member of. |
| 46 | |
| 47 | ### What the server sees (accepted metadata posture) |
| 48 | |
| 49 | Routing requires the server to know the **social graph**: thread membership, who |
| 50 | sent each message, message timing/size, and roster display names. It never sees |
| 51 | message content, attachment bytes, or thread/group names. Hiding the graph too |
| 52 | (sealed-sender / private membership) is explicitly out of scope. |
| 53 | |
| 54 | ## Data model (additive migrations) |
| 55 | |
| 56 | - `users(id, label, created_at, public_key)` β `public_key` added; registered on |
| 57 | first join. (Roster = this table.) |
| 58 | - `threads(id, kind, created_at, name_blob)` β `kind` β self|dm|group; |
| 59 | `name_blob` is the client-encrypted group name (null for dm/self). |
| 60 | - `thread_members(thread_id, user_id, created_at)` β membership (the graph). |
| 61 | - `thread_keys(thread_id, user_id, wrapped)` β the thread key wrapped to each |
| 62 | member's user public key. One row per member; server-blind. |
| 63 | - `messages` gains `thread_id`; reactions key off `(thread_id, message_id, β¦)`. |
| 64 | - `dm_pairs(user_a, user_b, thread_id)` β canonical dedupe so there's exactly one |
| 65 | DM per pair (`user_a < user_b`). |
| 66 | |
| 67 | ## Key flows |
| 68 | |
| 69 | - **Register pubkey** β on first load with a session, the browser generates the |
| 70 | user keypair (if absent), stores the private key in the session, and `PUT`s the |
| 71 | public key to the roster. |
| 72 | - **Start a DM** β pick someone from the roster; if no `dm_pairs` row exists, |
| 73 | generate a thread key, wrap it to *both* user pubkeys, create the thread + |
| 74 | members + dm_pair. Otherwise open the existing one. |
| 75 | - **Create a group** β name it, pick members; generate a thread key, wrap it to |
| 76 | each member's pubkey, store name_blob. Creator is owner. |
| 77 | - **Add a member** (existing instance member) β owner/any member fetches the |
| 78 | invitee's roster pubkey, wraps the current thread key to it, adds the row. |
| 79 | - **Send / receive** β WS carries `{thread_id, blob}`; the server fans out only |
| 80 | to that thread's members and gates reads by membership. Client decrypts with |
| 81 | the thread key it unwrapped (and caches). |
| 82 | - **Onboarding (new person β instance)** β admin-only. The invite, as today, |
| 83 | delivers the **user keypair** (generated by the inviter or the joiner) and |
| 84 | **seeds a DM with the inviter** (its thread key wrapped into the invite). The |
| 85 | joiner lands with one working conversation and a roster. |
| 86 | - **Add a device** (same person) β the existing device bundles the **user private |
| 87 | key** into the add-device link (under `HKDF(T)`); the new device unwraps it and |
| 88 | can then open every thread the user belongs to (their wrapped blobs are on the |
| 89 | server). No per-thread re-wrap needed β the win of user keypairs. |
| 90 | |
| 91 | ### Deferred (designed, not in v1) |
| 92 | |
| 93 | - **Remove a member / rekey** β admin removes from the instance, or owner from a |
| 94 | group: rotate the thread key, re-wrap to the remaining members, so the removed |
| 95 | party can't read *future* messages (past messages they already saw). Needs a UI |
| 96 | + a rotation endpoint. |
| 97 | - **Group join links** (instance-members-only, device-token-gated), mute, |
| 98 | restricted discovery modes, production migration of the 5 live instances. |
| 99 | |
| 100 | ## UI |
| 101 | |
| 102 | - **Desktop**: master-detail β thread list (left), open thread (right). The list |
| 103 | shows DMs by counterpart name, groups by name, newest activity first. |
| 104 | - **Mobile**: single pane that navigates list β thread β back (and a "people" |
| 105 | view for starting a new chat from the roster). |
| 106 | - **Thread header**: DM = the other person's name; group = its name; self = |
| 107 | "Notes". The instance name stays the app/title-bar name. |
| 108 | - A **"new chat"** action opens the roster picker (DM = one person, group = |
| 109 | several + a name). |
| 110 | |
| 111 | ## Capabilities (default β safe-for-kids) |
| 112 | |
| 113 | | Action | Who | |
| 114 | |---|---| |
| 115 | | Create group / start DM / add **existing** instance members | any member | |
| 116 | | Rename a group / remove a group member | the group's owner | |
| 117 | | Add a **new** person to the instance (roster) | admin only | |
| 118 | | Remove a person from the instance (+ rekey) | admin only | |
| 119 | |
| 120 | The admin guards the trust boundary; members move freely inside it. Restricted |
| 121 | discovery (e.g. kids can only DM parents) is a later opt-in; default is open. |
| 122 | |
| 123 | ## Migration (live instances β later) |
| 124 | |
| 125 | The existing single chat becomes one **group thread** keyed with the current |
| 126 | group key: create a `threads` row, attach all current members, wrap the existing |
| 127 | group key to each member's pubkey (their browser does this on first load, since |
| 128 | it already holds the group key from its session). Legacy `#k=` links and device |
| 129 | tokens keep working. History is preserved in that thread. Built and run at merge |
| 130 | time, after the model is proven on a fresh `test`. |
| 131 | |