eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– Invite redesign β€” short links, server-blind key delivery
2
3Status: Phase 1 (link + QR). Phase 2 (spoken word-code via PAKE) is parked.
4
5## Goal
6
7Short, single-use, revocable invites with multi-device support and admin
8re-issue β€” **without the server ever storing anything that could recover the
9group key.** Same hard guarantee as today ("compromise the server, get
10nothing"), but nicer onboarding and manageable invites.
11
12## Crypto (load-bearing)
13
14- Invite secret `T` = 128-bit random β†’ ~22 base64url chars, carried in the URL
15 **fragment**: `…/join/<label>#<T>`. The fragment is never sent to the server.
16- The client derives two values from `T` (HKDF-SHA256, domain-separated):
17 - `id = HKDF(T, "lchat-invite-id")` β€” the server's lookup handle.
18 - `wrapKey = HKDF(T, "lchat-invite-wrap")` β€” an AES-GCM key.
19- Minting (admin's browser, which holds the group key via the master link):
20 `wrapped = AES-GCM(groupKeyRaw, wrapKey)`. The browser POSTs only `{id, wrapped}`.
21- **The server stores `{id, wrapped}` and never sees `T`** β†’ it can't unwrap.
22 A stolen DB yields `{hash-like id, ciphertext}`; recovering the key needs the
23 128-bit `T`, which never left the client.
24
25The path segment (`<label>`) is cosmetic (server-visible, guessable) β€” all
26entropy lives in `T`.
27
28## Data model (additive β€” old invites/tokens keep working)
29
30- `users(id, label, created_at)` β€” a person.
31- `key_invites(id, user_id, wrapped, created_at, expires_at, used_at)` β€”
32 server-blind wrapped key per invite; `id` is the HKDF handle.
33- `device_tokens(token_hash, user_id, label, created_at)` β€” ongoing transport
34 credential (hashed, like the admin token), one per device.
35
36Messages remain opaque blobs; `author_hash` (already present from message
37editing) keeps working per device. Full "messages attributed to users" UI is
38out of Phase 1 scope β€” the model just makes it possible later.
39
40## Flows
41
42**Mint (admin):** browser generates `T`, derives `id`/`wrapKey`, wraps the group
43key β†’ `POST /api/admin/invites {label, id, wrapped, expires}`. Server creates the
44user (if new) + `key_invite`. Browser shows the link `…/join/<label>#<T>` + a QR.
45
46**Join (new device):**
471. Open `…/join/<label>#<T>` β†’ `T` from the fragment.
482. `GET /api/invite/<id>` β†’ `wrapped`; unwrap with `wrapKey` β†’ group key (stored
49 locally, same session shape as today: `{token, key, name}`).
503. `POST /api/invite/<id>/claim` β†’ server marks it used (single-use) and issues a
51 **device token**. Ongoing auth (messages/WebSocket/push) uses that token; `T`
52 is one-time and never sent.
53
54**Add another device / re-issue a lost one:** mint a fresh invite (new `T`, new
55wrapped blob) from any device that holds the key β€” admin, or your own existing
56device. QR is the clean desktop→mobile path.
57
58## Transport auth (additive)
59
60`tokenOK(token)` accepts **either** a current non-revoked invite token (old model)
61**or** a valid device token (new model). So existing members are unaffected and
62new joiners use device tokens.
63
64## Migration
65
66Fully additive β€” no data loss, nothing forced:
67- Old `#k=<key>` links and existing tokens keep working.
68- The existing group key is reused (the admin's browser wraps it) β€” no key
69 regeneration, no re-onboarding.
70- Old-style links age out as people move to new invites.
71
72The join page handles both: fragment `k=<key>` β†’ old flow; raw `<T>` β†’ new flow.
73
74## Phase 2 (parked): spoken word-code
75
76Four-ish words via **PAKE/SAS** (Magic-Wormhole style): the server relays an
77ephemeral key exchange it can't read; the words authenticate the live exchange
78against MITM; the key goes device-to-device. Low entropy is cryptographically
79safe in that construction. Documented now, built later.
80