eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– Threads & DMs β€” multiple conversations per instance
2
3Status: **in progress** (branch `threads-and-dms`, testing on `test`). This builds
4on `docs/invite-redesign.md` (users, device tokens, server-blind key delivery).
5
6Today one instance = one group chat with a single group key. This adds **multiple
7threads** β€” DMs, groups, and a personal "self" thread β€” within one instance,
8without weakening the E2EE invariant: **the server still only ever stores
9ciphertext 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
49Routing requires the server to know the **social graph**: thread membership, who
50sent each message, message timing/size, and roster display names. It never sees
51message 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
120The admin guards the trust boundary; members move freely inside it. Restricted
121discovery (e.g. kids can only DM parents) is a later opt-in; default is open.
122
123## Migration (live instances β€” later)
124
125The existing single chat becomes one **group thread** keyed with the current
126group key: create a `threads` row, attach all current members, wrap the existing
127group key to each member's pubkey (their browser does this on first load, since
128it already holds the group key from its session). Legacy `#k=` links and device
129tokens keep working. History is preserved in that thread. Built and run at merge
130time, after the model is proven on a fresh `test`.
131