# 🤖 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 sees `T`** → it can't unwrap.
  A stolen DB yields `{hash-like id, ciphertext}`; recovering the key needs the
  128-bit `T`, 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; `id` is 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):**
1. Open `…/join/<label>#<T>` → `T` from the fragment.
2. `GET /api/invite/<id>` → `wrapped`; unwrap with `wrapKey` → group key (stored
   locally, same session shape as today: `{token, key, name}`).
3. `POST /api/invite/<id>/claim` → server marks it used (single-use) and issues a
   **device token**. Ongoing auth (messages/WebSocket/push) uses that token; `T`
   is 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.
