eleven / ocho Public

Invite redesign: short links + QR, server-blind key delivery (#15)

Short single-use invite links (/join/<label>#<T>) where the server stores only a
client-wrapped key it can't unlock, plus QR codes, device tokens, and user
accounts with multiple devices. Adds member self-service "Add a device", an
iOS join-in-Safari → mint → Home-Screen flow, paste-to-join for new links, a
first-time-only screen-name prompt, per-person join status in the admin People
list, and copy/link affordances in the invite modals. Legacy #k= links and
tokens keep working (additive migration).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 8229f648eadfd93ab1c678559a6fa2998c77a3a2 parent def812f
15 files changed, +1211 −121
  • db.go +164 −0
  • db_test.go +53 −0
  • docs/invite-redesign.md +79 −0
  • handlers_test.go +123 −0
  • hub.go +4 −4
  • main.go +153 −9
  • test/crypto.test.mjs +29 −0
  • web/pages/admin.html +7 −5
  • web/pages/index.html +4 −3
  • web/pages/join.html +26 −1
  • web/static/admin.js +110 −62
  • web/static/chat.js +243 −12
  • web/static/crypto.js +36 −0
  • web/static/join.js +179 −25
  • web/static/style.css +1 −0
diff --git a/db.go b/db.go
index 2a83438..209a6ef 100644
--- a/db.go
+++ b/db.go
@@ -97,6 +97,25 @@ CREATE TABLE IF NOT EXISTS blobs (
size INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
+CREATE TABLE IF NOT EXISTS users (
+ id TEXT PRIMARY KEY,
+ label TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL
+);
+CREATE TABLE IF NOT EXISTS key_invites (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ wrapped TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER,
+ used_at INTEGER
+);
+CREATE TABLE IF NOT EXISTS device_tokens (
+ token_hash TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ label TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL
+);
CREATE TABLE IF NOT EXISTS link_previews (
url TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
@@ -316,6 +335,151 @@ func (db *DB) revokeInvite(token string) error {
return err
}
+// ---- users, key-invites, device tokens (invite redesign) ----------------
+//
+// The server stores a client-WRAPPED group key per invite, keyed by an id the
+// client derives from a secret it never sends (see docs/invite-redesign.md). The
+// server can't unwrap. Claiming an invite mints a device token for ongoing auth.
+
+type User struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+ CreatedAt int64 `json:"created_at"`
+ // Devices is how many devices have claimed in (0 = invited but not joined).
+ Devices int `json:"devices"`
+ // JoinedAt is when they first claimed a device (nil until they join).
+ JoinedAt *int64 `json:"joined_at,omitempty"`
+ // Pending is true while an unused, unexpired invite link is still out there.
+ Pending bool `json:"pending"`
+}
+
+func (db *DB) createUser(label string) (string, error) {
+ id, err := randToken()
+ if err != nil {
+ return "", err
+ }
+ _, err = db.sql.Exec("INSERT INTO users(id, label, created_at) VALUES(?, ?, ?)", id, label, time.Now().Unix())
+ return id, err
+}
+
+func (db *DB) listUsers() ([]User, error) {
+ now := time.Now().Unix()
+ rows, err := db.sql.Query(`
+ SELECT u.id, u.label, u.created_at,
+ (SELECT COUNT(*) FROM device_tokens d WHERE d.user_id = u.id),
+ (SELECT MIN(d.created_at) FROM device_tokens d WHERE d.user_id = u.id),
+ (SELECT COUNT(*) FROM key_invites k WHERE k.user_id = u.id
+ AND k.used_at IS NULL AND (k.expires_at IS NULL OR k.expires_at > ?))
+ FROM users u ORDER BY u.created_at DESC`, now)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []User{}
+ for rows.Next() {
+ var u User
+ var joinedAt sql.NullInt64
+ var pending int
+ if err := rows.Scan(&u.ID, &u.Label, &u.CreatedAt, &u.Devices, &joinedAt, &pending); err != nil {
+ return nil, err
+ }
+ if joinedAt.Valid {
+ j := joinedAt.Int64
+ u.JoinedAt = &j
+ }
+ u.Pending = pending > 0
+ out = append(out, u)
+ }
+ return out, rows.Err()
+}
+
+func (db *DB) userExists(id string) bool {
+ var x int
+ return db.sql.QueryRow("SELECT 1 FROM users WHERE id = ?", id).Scan(&x) == nil
+}
+
+// createKeyInvite stores a client-wrapped key under a client-derived id.
+func (db *DB) createKeyInvite(id, userID, wrapped string, expiresAt int64) error {
+ var exp any
+ if expiresAt > 0 {
+ exp = expiresAt
+ }
+ _, err := db.sql.Exec(
+ "INSERT INTO key_invites(id, user_id, wrapped, created_at, expires_at) VALUES(?, ?, ?, ?, ?)",
+ id, userID, wrapped, time.Now().Unix(), exp,
+ )
+ return err
+}
+
+type KeyInvite struct {
+ ID string
+ UserID string
+ Wrapped string
+ Used bool
+ Expired bool
+}
+
+func (db *DB) getKeyInvite(id string) (KeyInvite, bool, error) {
+ var ki KeyInvite
+ var used, exp sql.NullInt64
+ err := db.sql.QueryRow(
+ "SELECT id, user_id, wrapped, used_at, expires_at FROM key_invites WHERE id = ?", id,
+ ).Scan(&ki.ID, &ki.UserID, &ki.Wrapped, &used, &exp)
+ if err == sql.ErrNoRows {
+ return KeyInvite{}, false, nil
+ }
+ if err != nil {
+ return KeyInvite{}, false, err
+ }
+ ki.Used = used.Valid
+ ki.Expired = exp.Valid && time.Now().Unix() > exp.Int64
+ return ki, true, nil
+}
+
+// claimKeyInvite marks an unused, unexpired invite as used (single-use) and mints
+// a device token for its user. Returns the plaintext token (shown once). ok=false
+// (no error) if the invite is unknown, used, expired, or lost a claim race.
+func (db *DB) claimKeyInvite(id, deviceLabel string) (string, bool, error) {
+ ki, found, err := db.getKeyInvite(id)
+ if err != nil || !found || ki.Used || ki.Expired {
+ return "", false, err
+ }
+ res, err := db.sql.Exec("UPDATE key_invites SET used_at = ? WHERE id = ? AND used_at IS NULL", time.Now().Unix(), id)
+ if err != nil {
+ return "", false, err
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ return "", false, nil // already claimed
+ }
+ tok, err := randToken()
+ if err != nil {
+ return "", false, err
+ }
+ _, err = db.sql.Exec(
+ "INSERT INTO device_tokens(token_hash, user_id, label, created_at) VALUES(?, ?, ?, ?)",
+ hashToken(tok), ki.UserID, deviceLabel, time.Now().Unix(),
+ )
+ if err != nil {
+ return "", false, err
+ }
+ return tok, true, nil
+}
+
+func (db *DB) deviceTokenValid(token string) bool {
+ var x int
+ return db.sql.QueryRow("SELECT 1 FROM device_tokens WHERE token_hash = ?", hashToken(token)).Scan(&x) == nil
+}
+
+// deviceTokenUser returns the user a device token belongs to (for self-service
+// "add a device"). ok=false for unknown or legacy (non-device) tokens.
+func (db *DB) deviceTokenUser(token string) (string, bool) {
+ var uid string
+ if db.sql.QueryRow("SELECT user_id FROM device_tokens WHERE token_hash = ?", hashToken(token)).Scan(&uid) != nil {
+ return "", false
+ }
+ return uid, true
+}
+
// ---- messages -----------------------------------------------------------
type Message struct {
diff --git a/db_test.go b/db_test.go
index f574585..603b834 100644
--- a/db_test.go
+++ b/db_test.go
@@ -232,3 +232,56 @@ func TestVAPIDPersists(t *testing.T) {
t.Fatal("VAPID keys should persist across calls")
}
}
+
+func findUser(t *testing.T, users []User, id string) User {
+ t.Helper()
+ for _, u := range users {
+ if u.ID == id {
+ return u
+ }
+ }
+ t.Fatalf("user %s not in list", id)
+ return User{}
+}
+
+func TestUserStatus(t *testing.T) {
+ db := newTestDB(t)
+
+ uid, err := db.createUser("Tester")
+ if err != nil {
+ t.Fatalf("createUser: %v", err)
+ }
+ if err := db.createKeyInvite("inv1", uid, "wrapped", 0); err != nil {
+ t.Fatalf("createKeyInvite: %v", err)
+ }
+
+ // Invited but not joined: no devices, no join time, link still outstanding.
+ users, err := db.listUsers()
+ if err != nil {
+ t.Fatalf("listUsers: %v", err)
+ }
+ if u := findUser(t, users, uid); u.Devices != 0 || u.JoinedAt != nil || !u.Pending {
+ t.Fatalf("invited: got devices=%d joinedAt=%v pending=%v", u.Devices, u.JoinedAt, u.Pending)
+ }
+
+ // Claim it: now joined on one device, no longer pending.
+ if _, ok, err := db.claimKeyInvite("inv1", "phone"); err != nil || !ok {
+ t.Fatalf("claim inv1: ok=%v err=%v", ok, err)
+ }
+ users, _ = db.listUsers()
+ if u := findUser(t, users, uid); u.Devices != 1 || u.JoinedAt == nil || u.Pending {
+ t.Fatalf("joined: got devices=%d joinedAt=%v pending=%v", u.Devices, u.JoinedAt, u.Pending)
+ }
+
+ // A second device link, claimed → two devices.
+ if err := db.createKeyInvite("inv2", uid, "wrapped", 0); err != nil {
+ t.Fatalf("createKeyInvite inv2: %v", err)
+ }
+ if _, ok, err := db.claimKeyInvite("inv2", "laptop"); err != nil || !ok {
+ t.Fatalf("claim inv2: ok=%v err=%v", ok, err)
+ }
+ users, _ = db.listUsers()
+ if u := findUser(t, users, uid); u.Devices != 2 || u.Pending {
+ t.Fatalf("two devices: got devices=%d pending=%v", u.Devices, u.Pending)
+ }
+}
diff --git a/docs/invite-redesign.md b/docs/invite-redesign.md
new file mode 100644
index 0000000..583fc51
--- /dev/null
+++ b/docs/invite-redesign.md
@@ -0,0 +1,79 @@
+# 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.
diff --git a/handlers_test.go b/handlers_test.go
index 34da310..a45fcb1 100644
--- a/handlers_test.go
+++ b/handlers_test.go
@@ -143,6 +143,129 @@ func TestMessagesIncludeReactions(t *testing.T) {
}
}
+func TestKeyInviteFlow(t *testing.T) {
+ ts, _ := newTestServer(t)
+
+ // Admin mints a key-invite. The browser would derive `id` and wrap the key;
+ // here we just store opaque strings (the server never interprets them).
+ resp := adminPost(t, ts.URL+"/api/admin/key-invites",
+ `{"label":"Paul","id":"derived-id-abc","wrapped":"wrapped-blob"}`)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("create key-invite: %d", resp.StatusCode)
+ }
+ // Without admin auth it's rejected.
+ r, _ := http.Post(ts.URL+"/api/admin/key-invites", "application/json",
+ strings.NewReader(`{"label":"x","id":"y","wrapped":"z"}`))
+ if r.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("create without admin: want 401, got %d", r.StatusCode)
+ }
+
+ // Anyone with the id can fetch the (encrypted) wrapped blob.
+ r, _ = http.Get(ts.URL + "/api/key-invite/derived-id-abc")
+ var got struct {
+ Ok bool `json:"ok"`
+ Wrapped string `json:"wrapped"`
+ }
+ json.NewDecoder(r.Body).Decode(&got)
+ if !got.Ok || got.Wrapped != "wrapped-blob" {
+ t.Fatalf("get key-invite: %+v", got)
+ }
+
+ // A random device token is not yet valid.
+ r, _ = http.Get(ts.URL + "/api/messages?token=randomdevice")
+ if r.StatusCode != http.StatusForbidden {
+ t.Fatalf("unclaimed device: want 403, got %d", r.StatusCode)
+ }
+
+ // Claiming mints a device token...
+ r, _ = http.Post(ts.URL+"/api/key-invite/derived-id-abc/claim", "application/json",
+ strings.NewReader(`{"label":"laptop"}`))
+ var claim struct {
+ Ok bool `json:"ok"`
+ Token string `json:"token"`
+ }
+ json.NewDecoder(r.Body).Decode(&claim)
+ if !claim.Ok || claim.Token == "" {
+ t.Fatalf("claim: %+v", claim)
+ }
+ // ...which then authorises the chat API.
+ r, _ = http.Get(ts.URL + "/api/messages?token=" + claim.Token)
+ if r.StatusCode != http.StatusOK {
+ t.Fatalf("device-token messages: %d", r.StatusCode)
+ }
+
+ // Single-use: a second claim fails and the wrapped blob is no longer served.
+ r, _ = http.Post(ts.URL+"/api/key-invite/derived-id-abc/claim", "application/json", strings.NewReader(`{}`))
+ if r.StatusCode == http.StatusOK {
+ t.Fatal("second claim should fail")
+ }
+ r, _ = http.Get(ts.URL + "/api/key-invite/derived-id-abc")
+ json.NewDecoder(r.Body).Decode(&got)
+ if got.Ok {
+ t.Fatal("used invite should report ok:false")
+ }
+}
+
+func TestMemberAddsDevice(t *testing.T) {
+ ts, _ := newTestServer(t)
+ // Mint + claim a key-invite to get a member device token.
+ adminPost(t, ts.URL+"/api/admin/key-invites", `{"label":"Paul","id":"inv-1","wrapped":"w1"}`)
+ r, _ := http.Post(ts.URL+"/api/key-invite/inv-1/claim", "application/json", strings.NewReader(`{}`))
+ var claim struct {
+ Token string `json:"token"`
+ }
+ json.NewDecoder(r.Body).Decode(&claim)
+
+ before := countUsers(t, ts) // 1: Paul
+
+ // The member mints a device invite for themselves (no admin token).
+ r, _ = http.Post(ts.URL+"/api/device-invites", "application/json",
+ strings.NewReader(`{"token":"`+claim.Token+`","label":"Paul","id":"inv-2","wrapped":"w2"}`))
+ if r.StatusCode != http.StatusOK {
+ t.Fatalf("member add-device: %d", r.StatusCode)
+ }
+ // A stranger (no valid token) cannot.
+ r, _ = http.Post(ts.URL+"/api/device-invites", "application/json",
+ strings.NewReader(`{"token":"bogus","id":"x","wrapped":"y"}`))
+ if r.StatusCode != http.StatusForbidden {
+ t.Fatalf("stranger add-device: want 403, got %d", r.StatusCode)
+ }
+
+ // The new invite is claimable and tied to the SAME user (no new user created).
+ r, _ = http.Get(ts.URL + "/api/key-invite/inv-2")
+ var got struct {
+ Ok bool `json:"ok"`
+ }
+ json.NewDecoder(r.Body).Decode(&got)
+ if !got.Ok {
+ t.Fatal("new device invite should be claimable")
+ }
+ if after := countUsers(t, ts); after != before {
+ t.Fatalf("add-device should reuse the user: before=%d after=%d", before, after)
+ }
+}
+
+func countUsers(t *testing.T, ts *httptest.Server) int {
+ t.Helper()
+ r := adminGet(t, ts.URL+"/api/admin/users")
+ var out struct {
+ Users []struct{ ID string } `json:"users"`
+ }
+ json.NewDecoder(r.Body).Decode(&out)
+ return len(out.Users)
+}
+
+func adminGet(t *testing.T, url string) *http.Response {
+ t.Helper()
+ req, _ := http.NewRequest("GET", url, nil)
+ req.Header.Set("Authorization", "Bearer admintok")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("request: %v", err)
+ }
+ return resp
+}
+
func TestActiveTokenTracking(t *testing.T) {
_, srv := newTestServer(t)
diff --git a/hub.go b/hub.go
index fbffab5..29bff21 100644
--- a/hub.go
+++ b/hub.go
@@ -111,8 +111,7 @@ var allowedReactions = map[string]bool{
func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
- exists, revoked, err := s.db.inviteStatus(token)
- if err != nil || !exists || revoked {
+ if !s.tokenOK(token) {
http.Error(w, "invalid invite", http.StatusForbidden)
return
}
@@ -162,8 +161,9 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
if in.Type != "msg" && in.Type != "edit" && in.Type != "react" && in.Type != "unreact" {
continue
}
- // Re-check revocation so a revoked client can't keep posting or reacting.
- if _, rev, err := s.db.inviteStatus(token); err != nil || rev {
+ // Re-check the credential so a revoked invite / removed device can't keep
+ // posting or reacting.
+ if !s.tokenOK(token) {
return
}
diff --git a/main.go b/main.go
index 8bf5449..12cf8c8 100644
--- a/main.go
+++ b/main.go
@@ -114,10 +114,19 @@ func (s *Server) handler() http.Handler {
// caches it, and serves cached results immediately (refreshing stale ones).
mux.HandleFunc("GET /api/unfurl", s.unfurl)
- // Invite lifecycle (used by the join page)
+ // Invite lifecycle (legacy join page)
mux.HandleFunc("GET /api/invite/{token}", s.getInvite)
mux.HandleFunc("POST /api/invite/{token}/claim", s.claimInvite)
+ // Key-invites (invite redesign): server stores a client-wrapped key it can't
+ // open; claiming mints a device token. See docs/invite-redesign.md.
+ mux.HandleFunc("GET /api/key-invite/{id}", s.getKeyInvite)
+ mux.HandleFunc("POST /api/key-invite/{id}/claim", s.claimKeyInvite)
+ mux.HandleFunc("POST /api/admin/key-invites", s.createKeyInvite)
+ mux.HandleFunc("GET /api/admin/users", s.listUsers)
+ // A member can mint a fresh invite for their own account (add a device).
+ mux.HandleFunc("POST /api/device-invites", s.createDeviceInvite)
+
// Group name (public read so the page/manifest can show it; admin write)
mux.HandleFunc("GET /api/group", s.getGroup)
mux.HandleFunc("POST /api/admin/group", s.setGroup)
@@ -172,8 +181,7 @@ func (s *Server) servePage(name string) http.HandlerFunc {
func (s *Server) getMessages(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
- exists, revoked, err := s.db.inviteStatus(token)
- if err != nil || !exists || revoked {
+ if !s.tokenOK(token) {
httpError(w, http.StatusForbidden, "invalid invite")
return
}
@@ -188,6 +196,7 @@ func (s *Server) getMessages(w http.ResponseWriter, r *http.Request) {
// `before` paginates older history (id < before); otherwise `after` returns
// the page after a cursor, or the most recent `limit` when after is 0.
var msgs []Message
+ var err error
if b := q.Get("before"); b != "" {
before, _ := strconv.ParseInt(b, 10, 64)
msgs, err = s.db.messagesBefore(before, limit)
@@ -214,10 +223,16 @@ func (s *Server) getMessages(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"messages": msgs})
}
-// tokenOK reports whether a token is a known, non-revoked invite.
+// tokenOK reports whether a token is a valid participant credential: either a
+// current non-revoked invite (legacy) or a device token (invite redesign).
func (s *Server) tokenOK(token string) bool {
- exists, revoked, err := s.db.inviteStatus(token)
- return err == nil && exists && !revoked
+ if token == "" {
+ return false
+ }
+ if exists, revoked, err := s.db.inviteStatus(token); err == nil && exists && !revoked {
+ return true
+ }
+ return s.db.deviceTokenValid(token)
}
// ---- attachments --------------------------------------------------------
@@ -355,6 +370,136 @@ func (s *Server) claimInvite(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
+// ---- key-invites (invite redesign) --------------------------------------
+
+// createKeyInvite stores a client-wrapped group key (the admin's browser did the
+// wrapping; the server never sees the secret or plaintext key). Creates a user
+// when no user_id is given (re-issue passes an existing user_id).
+func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) {
+ if !s.requireAdmin(w, r) {
+ return
+ }
+ var body struct {
+ Label string `json:"label"`
+ UserID string `json:"user_id"`
+ ID string `json:"id"`
+ Wrapped string `json:"wrapped"`
+ Expires int64 `json:"expires"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ if body.ID == "" || body.Wrapped == "" || len(body.ID) > 200 || len(body.Wrapped) > 4096 {
+ httpError(w, http.StatusBadRequest, "missing id/wrapped")
+ return
+ }
+ userID := body.UserID
+ if userID == "" {
+ id, err := s.db.createUser(strings.TrimSpace(body.Label))
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ userID = id
+ } else if !s.db.userExists(userID) {
+ httpError(w, http.StatusBadRequest, "unknown user")
+ return
+ }
+ if err := s.db.createKeyInvite(body.ID, userID, body.Wrapped, body.Expires); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "user_id": userID})
+}
+
+// getKeyInvite returns the wrapped key for an unused, unexpired invite. The id is
+// the client-derived handle; without the secret the wrapped blob is useless.
+func (s *Server) getKeyInvite(w http.ResponseWriter, r *http.Request) {
+ ki, found, err := s.db.getKeyInvite(r.PathValue("id"))
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ ok := found && !ki.Used && !ki.Expired
+ resp := map[string]any{"ok": ok}
+ if ok {
+ resp["wrapped"] = ki.Wrapped
+ }
+ writeJSON(w, http.StatusOK, resp)
+}
+
+// claimKeyInvite marks the invite used (single-use) and mints a device token for
+// ongoing auth. The secret never reaches the server — only this claim does.
+func (s *Server) claimKeyInvite(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Label string `json:"label"`
+ }
+ json.NewDecoder(r.Body).Decode(&body)
+ tok, ok, err := s.db.claimKeyInvite(r.PathValue("id"), strings.TrimSpace(body.Label))
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ if !ok {
+ httpError(w, http.StatusForbidden, "invite unavailable")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "token": tok})
+}
+
+func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) {
+ if !s.requireAdmin(w, r) {
+ return
+ }
+ users, err := s.db.listUsers()
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"users": users})
+}
+
+// createDeviceInvite lets a member mint a fresh invite for their OWN account (to
+// add another device), authenticated by their existing token. The browser wraps
+// the key it already holds; the server stays blind. Ties to the device token's
+// user, or creates one for a legacy member.
+func (s *Server) createDeviceInvite(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ Label string `json:"label"`
+ ID string `json:"id"`
+ Wrapped string `json:"wrapped"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ if !s.tokenOK(body.Token) {
+ httpError(w, http.StatusForbidden, "not a member")
+ return
+ }
+ if body.ID == "" || body.Wrapped == "" || len(body.ID) > 200 || len(body.Wrapped) > 4096 {
+ httpError(w, http.StatusBadRequest, "missing id/wrapped")
+ return
+ }
+ userID, ok := s.db.deviceTokenUser(body.Token)
+ if !ok {
+ // Legacy member (joined via an old #k= link) — give them a user now.
+ id, err := s.db.createUser(strings.TrimSpace(body.Label))
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ userID = id
+ }
+ if err := s.db.createKeyInvite(body.ID, userID, body.Wrapped, 0); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
// ---- admin API ----------------------------------------------------------
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
@@ -430,9 +575,8 @@ func (s *Server) subscribePush(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusBadRequest, "bad request")
return
}
- // Only valid (non-revoked) participants may register for notifications.
- exists, revoked, err := s.db.inviteStatus(body.Token)
- if err != nil || !exists || revoked {
+ // Only valid participants (invite or device token) may register for push.
+ if !s.tokenOK(body.Token) {
httpError(w, http.StatusForbidden, "invalid invite")
return
}
diff --git a/test/crypto.test.mjs b/test/crypto.test.mjs
index dfe510e..8a0b1b0 100644
--- a/test/crypto.test.mjs
+++ b/test/crypto.test.mjs
@@ -10,6 +10,10 @@ import {
decryptBytes,
b64urlToBytes,
bytesToB64url,
+ randomSecret,
+ deriveInvite,
+ wrapGroupKey,
+ unwrapGroupKey,
} from "../web/static/crypto.js";
let failures = 0;
@@ -82,5 +86,30 @@ await test("encryptBytes output is not the plaintext", async () => {
assert.notDeepEqual(Array.from(enc.slice(12)), Array.from(original));
});
+await test("invite: same secret derives the same id (stable lookup)", async () => {
+ const T = randomSecret();
+ const a = await deriveInvite(T);
+ const b = await deriveInvite(T);
+ assert.equal(a.id, b.id);
+});
+
+await test("invite: a different secret derives a different id", async () => {
+ const a = await deriveInvite(randomSecret());
+ const b = await deriveInvite(randomSecret());
+ assert.notEqual(a.id, b.id);
+});
+
+await test("invite: wrap → unwrap recovers the group key; wrong secret can't", async () => {
+ const { b64: groupKey } = await generateKey();
+ const T = randomSecret();
+ const { wrapKey } = await deriveInvite(T);
+ const wrapped = await wrapGroupKey(groupKey, wrapKey);
+ assert.ok(!wrapped.includes(groupKey), "wrapped must not contain the raw key");
+ assert.equal(await unwrapGroupKey(wrapped, wrapKey), groupKey);
+
+ const { wrapKey: other } = await deriveInvite(randomSecret());
+ await assert.rejects(() => unwrapGroupKey(wrapped, other));
+});
+
console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
process.exit(failures === 0 ? 0 : 1);
diff --git a/web/pages/admin.html b/web/pages/admin.html
index 579da3d..24f35f3 100644
--- a/web/pages/admin.html
+++ b/web/pages/admin.html
@@ -64,10 +64,11 @@
</section>
<section>
- <h2>Create an invite</h2>
+ <h2>Invite someone</h2>
+ <p class="sub" style="margin:0 0 8px">Creates a short, single-use link (and QR) for one person. The server never sees the chat key.</p>
<form id="createForm" class="inline-form" autocomplete="off">
- <input id="label" placeholder="Who's this for? (e.g. Sophie) — optional" maxlength="60" />
- <button type="submit">Create invite link</button>
+ <input id="label" placeholder="Their name (e.g. Sophie)" maxlength="60" />
+ <button type="submit">Create invite</button>
</form>
</section>
@@ -78,8 +79,9 @@
</section>
<section>
- <h2>Invites</h2>
- <div id="invites"><p class="sub">Loading…</p></div>
+ <h2>People</h2>
+ <p class="sub" style="margin:0 0 8px">Each person can have several devices. Re-issue a link to add a device or recover a lost one — it shares the same chat key.</p>
+ <div id="people"><p class="sub">Loading…</p></div>
</section>
</div>
</div>
diff --git a/web/pages/index.html b/web/pages/index.html
index 313e67a..bd164e0 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -22,6 +22,7 @@
<div class="menu-who" id="menu-who"></div>
<button class="menu-item" id="menu-notif" role="menuitem"></button>
<button class="menu-item" id="menu-rename" role="menuitem">Change screen name</button>
+ <button class="menu-item" id="menu-add-device" role="menuitem">Add a device</button>
<button class="menu-item" id="menu-refresh" role="menuitem">Refresh</button>
<button class="menu-item danger" id="menu-leave" role="menuitem">Leave chat</button>
</div>
@@ -56,10 +57,10 @@
<div class="center">
<div class="card">
<h1>Join the chat</h1>
- <p>Paste your secret invite link and pick a screen name.</p>
+ <p id="join-intro">Paste your secret invite link to join.</p>
<form id="paste-join" autocomplete="off">
<input id="link" placeholder="Paste your invite link" autocomplete="off" />
- <input id="name" placeholder="Screen name (e.g. Sophie)" maxlength="32" autocomplete="off" style="margin-top: 10px" />
+ <input id="name" placeholder="Screen name (e.g. Sophie)" maxlength="32" autocomplete="off" class="hidden" style="margin-top: 10px" />
<div class="row"><button type="submit">Join the chat</button></div>
</form>
<p id="join-err" class="banner warn hidden" style="text-align:left;padding:0;margin-top:10px"></p>
@@ -76,7 +77,7 @@
<ol class="steps">
<li>Tap the <strong>Share</strong> button (the square with an up arrow) in Safari's toolbar.</li>
<li>Scroll down and choose <strong>Add to Home Screen</strong>.</li>
- <li>Open <strong>__GROUP_NAME__</strong> from your Home Screen and join there.</li>
+ <li>Open <strong>__GROUP_NAME__</strong> from your Home Screen, then paste your invite link to join.</li>
</ol>
<div class="row"><button id="a2hs-continue" class="secondary">Continue in browser</button></div>
</div>
diff --git a/web/pages/join.html b/web/pages/join.html
index 4e8a408..f5dc36a 100644
--- a/web/pages/join.html
+++ b/web/pages/join.html
@@ -1,7 +1,15 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
-<title>Join the chat</title>
+<meta name="theme-color" content="#5b6cff" />
+<title>__GROUP_NAME__</title>
<link rel="stylesheet" href="/static/style.css" />
+<!-- Same identity as the chat root, so "Add to Home Screen" from here installs as
+ __GROUP_NAME__ (not a generic "Join") and launches at the app's start_url ("/"). -->
+<link rel="manifest" href="/manifest.webmanifest" />
+<link rel="icon" href="/static/icon-192.png" />
+<link rel="apple-touch-icon" href="/static/icon-180.png" />
+<meta name="apple-mobile-web-app-capable" content="yes" />
+<meta name="apple-mobile-web-app-title" content="__GROUP_NAME__" />
<div class="center">
<div class="card" id="card">
@@ -9,6 +17,23 @@
</div>
</div>
+<template id="homescreen-tpl">
+ <div class="onboard-icon">📲</div>
+ <h1>You're in! Add it to your Home Screen</h1>
+ <p>
+ You're signed in here in Safari. On iPhone &amp; iPad, add the chat to your Home
+ Screen too, so it opens like an app and can send you notifications. You'll sign in
+ there once, with the link below.
+ </p>
+ <ol class="steps">
+ <li>Tap <strong>Copy sign-in link</strong> below.</li>
+ <li>Tap the <strong>Share</strong> button (the square with an up arrow) in Safari, then choose <strong>Add to Home Screen</strong>.</li>
+ <li>Open the app from your Home Screen and <strong>paste the link</strong> to sign in.</li>
+ </ol>
+ <div class="row"><button id="hs-copy">Copy sign-in link</button></div>
+ <div class="row"><button id="hs-open" class="secondary">Just use Safari for now</button></div>
+</template>
+
<template id="form-tpl">
<h1>Pick a screen name</h1>
<p>This is what everyone in the chat will see. You can use your first name or a nickname.</p>
diff --git a/web/static/admin.js b/web/static/admin.js
index 87c7ae5..0bca95b 100644
--- a/web/static/admin.js
+++ b/web/static/admin.js
@@ -1,4 +1,10 @@
-import { generateKey, importKey } from "/static/crypto.js";
+import {
+ generateKey,
+ importKey,
+ randomSecret,
+ deriveInvite,
+ wrapGroupKey,
+} from "/static/crypto.js";
import { qrCanvas } from "/static/qrcode.js";
const ADMIN_KEY = "lchat_admin_token";
@@ -21,16 +27,23 @@ init();
async function init() {
if (!adminToken) return show(locked);
- // Verify the token by listing invites (also tells us whether the chat already exists).
- const res = await api("GET", "/api/admin/invites");
+ // Verify the token, and learn whether the chat already has members (legacy
+ // invites or new-model users) so we don't mint a fresh, conflicting key.
+ const res = await api("GET", "/api/admin/users");
if (!res.ok) {
localStorage.removeItem(ADMIN_KEY);
return show(locked);
}
- const invites = (await res.json()).invites || [];
+ const users = (await res.json()).users || [];
+ let legacyCount = 0;
+ try {
+ const inv = await api("GET", "/api/admin/invites");
+ if (inv.ok) legacyCount = ((await inv.json()).invites || []).length;
+ } catch {}
+ const chatExists = users.length > 0 || legacyCount > 0;
// Resolve the chat key before showing anything that depends on it.
- if (!(await resolveGroupKey(invites))) return; // key-required screen shown; await paste
+ if (!(await resolveGroupKey(chatExists))) return; // key-required screen shown; await paste
hide(locked);
hide(keyRequired);
@@ -42,13 +55,13 @@ async function init() {
wireTestPush();
wireCreate();
refreshKeyUI();
- renderInvites(invites);
+ renderUsers(users);
}
// ---- group key ----------------------------------------------------------
// Decide which key this browser should use, robustly avoiding accidental splits.
-async function resolveGroupKey(invites) {
+async function resolveGroupKey(chatExists) {
// 1. A key in the fragment is the canonical "master link" — always adopt it.
if (fragKey) {
try {
@@ -61,8 +74,8 @@ async function resolveGroupKey(invites) {
}
// 2. A key from a previous visit on this browser.
if (localStorage.getItem(GROUP_KEY)) return true;
- // 3. No key anywhere: only mint one for a brand-new chat (nobody invited yet).
- if (invites.length === 0) {
+ // 3. No key anywhere: only mint one for a brand-new chat (no members yet).
+ if (!chatExists) {
const { b64 } = await generateKey();
localStorage.setItem(GROUP_KEY, b64);
return true;
@@ -157,40 +170,59 @@ function wireTestPush() {
};
}
-// ---- invites ------------------------------------------------------------
+// ---- invites & people ---------------------------------------------------
function wireCreate() {
document.getElementById("createForm").onsubmit = async (e) => {
e.preventDefault();
const label = document.getElementById("label").value.trim();
- const res = await api("POST", "/api/admin/invites", { label });
- if (!res.ok) return toast("Couldn't create invite");
- const inv = await res.json();
+ const minted = await mintInvite(label, null);
+ if (!minted) return;
document.getElementById("label").value = "";
- // Copy the new link straight away, then refresh the list (no blocking dialog).
- copy(linkFor(inv.token), "Invite link copied");
- refresh();
+ showInviteModal(minted.link, label || "New invite");
+ loadUsers();
};
}
-async function refresh() {
- const res = await api("GET", "/api/admin/invites");
- if (res.ok) renderInvites((await res.json()).invites || []);
+// Mint a single-use invite: generate the secret T client-side, wrap the group
+// key under it, and store only {id, wrapped} server-side. Pass a userId to
+// re-issue for an existing person (add/recover a device). Returns the link.
+async function mintInvite(label, userId) {
+ const groupKey = localStorage.getItem(GROUP_KEY);
+ if (!groupKey) {
+ toast("No chat key in this browser");
+ return null;
+ }
+ const T = randomSecret();
+ const { id, wrapKey } = await deriveInvite(T);
+ const wrapped = await wrapGroupKey(groupKey, wrapKey);
+ const res = await api("POST", "/api/admin/key-invites", {
+ label,
+ user_id: userId || "",
+ id,
+ wrapped,
+ });
+ if (!res.ok) {
+ toast("Couldn't create invite");
+ return null;
+ }
+ const slug = (label || "join").trim().replace(/[^\w]+/g, "-").replace(/^-+|-+$/g, "") || "join";
+ return { link: `${location.origin}/join/${encodeURIComponent(slug)}#${T}` };
}
-function linkFor(token) {
- const key = localStorage.getItem(GROUP_KEY);
- return `${location.origin}/join/${token}#k=${key}`;
+async function loadUsers() {
+ const res = await api("GET", "/api/admin/users");
+ if (res.ok) renderUsers((await res.json()).users || []);
}
-function renderInvites(invites) {
- const box = document.getElementById("invites");
+function renderUsers(users) {
+ const box = document.getElementById("people");
box.innerHTML = "";
- if (!invites.length) {
- box.innerHTML = `<p class="sub">No invites yet. Create one above.</p>`;
+ if (!users.length) {
+ box.innerHTML = `<p class="sub">No one yet. Invite someone above.</p>`;
return;
}
- for (const inv of invites) {
+ for (const u of users) {
const row = document.createElement("div");
row.className = "invite";
@@ -198,41 +230,35 @@ function renderInvites(invites) {
meta.className = "meta";
const label = document.createElement("div");
label.className = "label";
- label.textContent = inv.label || "(no label)";
+ label.textContent = u.label || "(no name)";
+ const joined = u.devices > 0;
+ const date = (t) => new Date(t * 1000).toLocaleDateString();
const status = document.createElement("div");
- status.className = "status" + (inv.revoked ? " revoked" : "");
- status.textContent = inv.revoked
- ? "revoked"
- : inv.claimed_at
- ? "joined " + new Date(inv.claimed_at * 1000).toLocaleDateString()
- : "not used yet";
+ status.className = "status";
+ if (joined) {
+ const when = date(u.joined_at || u.created_at);
+ status.textContent = u.devices > 1 ? `joined ${when} · ${u.devices} devices` : `joined ${when}`;
+ } else if (u.pending) {
+ status.textContent = `invited ${date(u.created_at)} · not joined yet`;
+ } else {
+ status.textContent = `invited ${date(u.created_at)} · link expired`;
+ }
meta.appendChild(label);
meta.appendChild(status);
row.appendChild(meta);
- if (!inv.revoked) {
- const copyBtn = document.createElement("button");
- copyBtn.className = "secondary";
- copyBtn.textContent = "Copy link";
- copyBtn.onclick = () => copy(linkFor(inv.token), "Invite link copied");
- row.appendChild(copyBtn);
-
- const qrBtn = document.createElement("button");
- qrBtn.className = "secondary";
- qrBtn.textContent = "QR";
- qrBtn.onclick = () => showQRModal(inv);
- row.appendChild(qrBtn);
-
- const revokeBtn = document.createElement("button");
- revokeBtn.className = "secondary";
- revokeBtn.textContent = "Revoke";
- revokeBtn.onclick = async () => {
- if (!confirm("Revoke this invite? They'll lose access to the server.")) return;
- await api("POST", `/api/admin/invites/${inv.token}/revoke`);
- refresh();
- };
- row.appendChild(revokeBtn);
- }
+ // Both actions mint a fresh single-use link for this same person; the wording
+ // reflects intent (add a device vs. re-send an invite that hasn't landed yet).
+ // Any earlier link still works — the server can't revoke what it can't unlock.
+ const reissue = document.createElement("button");
+ reissue.className = "secondary";
+ reissue.textContent = joined ? "New device link" : "Resend link";
+ reissue.onclick = async () => {
+ const minted = await mintInvite(u.label, u.id);
+ if (minted) showInviteModal(minted.link, u.label || (joined ? "New device" : "Invite"));
+ };
+ row.appendChild(reissue);
+
box.appendChild(row);
}
}
@@ -241,9 +267,7 @@ function renderInvites(invites) {
// the FULL join link — including the #k= key fragment — so it's rendered here in
// the browser (never server-side, which would leak the key) and must be treated
// like the secret link: anyone who scans it can read the whole chat.
-function showQRModal(inv) {
- const link = linkFor(inv.token);
-
+function showInviteModal(link, title) {
// Full-screen, opaque takeover — hides the rest of the admin console so only
// the QR is on screen (easier to scan, nothing sensitive shown behind it).
const backdrop = document.createElement("div");
@@ -252,7 +276,7 @@ function showQRModal(inv) {
card.className = "card qr-modal";
const h = document.createElement("h2");
- h.textContent = inv.label ? `Invite: ${inv.label}` : "Invite QR";
+ h.textContent = title ? `Invite: ${title}` : "Invite";
card.appendChild(h);
const note = document.createElement("p");
@@ -270,6 +294,21 @@ function showQRModal(inv) {
}
card.appendChild(wrap);
+ // Show the full link too, so it can be selected and copied by hand if the
+ // clipboard button is blocked. Tap to select all. It IS the secret — same
+ // caveat as the QR: anyone with it can read the whole chat.
+ const linkBox = document.createElement("div");
+ linkBox.className = "key-box mono";
+ linkBox.textContent = link;
+ linkBox.onclick = () => {
+ const range = document.createRange();
+ range.selectNodeContents(linkBox);
+ const sel = window.getSelection();
+ sel.removeAllRanges();
+ sel.addRange(range);
+ };
+ card.appendChild(linkBox);
+
const close = () => {
backdrop.remove();
document.removeEventListener("keydown", onKey);
@@ -283,7 +322,16 @@ function showQRModal(inv) {
const copyBtn = document.createElement("button");
copyBtn.className = "secondary";
copyBtn.textContent = "Copy link";
- copyBtn.onclick = () => copy(link, "Invite link copied");
+ // In-modal feedback: a toast would render behind this full-screen overlay.
+ copyBtn.onclick = async () => {
+ try {
+ await navigator.clipboard.writeText(link);
+ copyBtn.textContent = "Link copied ✓";
+ } catch {
+ copyBtn.textContent = "Select the link above to copy";
+ }
+ setTimeout(() => (copyBtn.textContent = "Copy link"), 2000);
+ };
const doneBtn = document.createElement("button");
doneBtn.textContent = "Done";
doneBtn.onclick = close;
diff --git a/web/static/chat.js b/web/static/chat.js
index 8ad202c..bbd552c 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -1,4 +1,15 @@
-import { importKey, encrypt, decrypt, encryptBytes, decryptBytes } from "/static/crypto.js";
+import {
+ importKey,
+ encrypt,
+ decrypt,
+ encryptBytes,
+ decryptBytes,
+ randomSecret,
+ deriveInvite,
+ wrapGroupKey,
+ unwrapGroupKey,
+} from "/static/crypto.js";
+import { qrCanvas } from "/static/qrcode.js";
import { shouldSend } from "/static/ui.js";
import { tokenizeText, formatBytes, replySnippet, emojiOnlyCount } from "/static/format.js";
import { MESSAGE_TYPE_CHAT, acceptsMessageType } from "/static/message.js";
@@ -220,6 +231,12 @@ function setupMenu(session) {
}
};
+ document.getElementById("menu-add-device").onclick = async (e) => {
+ e.stopPropagation();
+ close();
+ await addDevice(session);
+ };
+
document.getElementById("menu-leave").onclick = (e) => {
e.stopPropagation();
if (confirm("Leave this chat on this device? You'll need your link to rejoin.")) {
@@ -275,6 +292,109 @@ function openLightbox(src, alt) {
document.body.appendChild(backdrop);
}
+// Mint a fresh single-use invite for the current user's OWN account (add another
+// device). The browser wraps the key it already holds; the server stays blind.
+async function addDevice(session) {
+ let link;
+ try {
+ const T = randomSecret();
+ const { id, wrapKey } = await deriveInvite(T);
+ const wrapped = await wrapGroupKey(session.key, wrapKey);
+ const res = await fetch("/api/device-invites", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, label: session.name, id, wrapped }),
+ });
+ if (!res.ok) throw new Error("server");
+ const slug =
+ (session.name || "device").trim().replace(/[^\w]+/g, "-").replace(/^-+|-+$/g, "") || "device";
+ // Carry your name in the fragment (after the secret) so your other device
+ // joins as you without re-typing it. The fragment is never sent to the
+ // server, so the name stays private. "~" can't appear in T (base64url).
+ link = `${location.origin}/join/${encodeURIComponent(slug)}#${T}~${encodeURIComponent(session.name)}`;
+ } catch {
+ showBanner("Couldn't create a device link. Try again.", true);
+ return;
+ }
+ showDeviceLinkModal(link);
+}
+
+function showDeviceLinkModal(link) {
+ const backdrop = document.createElement("div");
+ backdrop.className = "qr-screen";
+ const card = document.createElement("div");
+ card.className = "card qr-modal";
+
+ const h = document.createElement("h2");
+ h.textContent = "Add a device";
+ card.appendChild(h);
+
+ const note = document.createElement("p");
+ note.className = "sub";
+ note.textContent =
+ "Open this on your other device, or scan the QR. It works once — anyone who uses it joins as you, so keep it to yourself.";
+ card.appendChild(note);
+
+ const wrap = document.createElement("div");
+ wrap.className = "qr-wrap";
+ try {
+ wrap.appendChild(qrCanvas(link, { scale: 6, margin: 4 }));
+ } catch {
+ wrap.textContent = "Couldn't render a QR code.";
+ }
+ card.appendChild(wrap);
+
+ // Show the full link too, so it can be selected/copied by hand. Tap to select.
+ const linkBox = document.createElement("div");
+ linkBox.className = "key-box mono";
+ linkBox.textContent = link;
+ linkBox.onclick = () => {
+ const range = document.createRange();
+ range.selectNodeContents(linkBox);
+ const sel = window.getSelection();
+ sel.removeAllRanges();
+ sel.addRange(range);
+ };
+ card.appendChild(linkBox);
+
+ const close = () => {
+ backdrop.remove();
+ document.removeEventListener("keydown", onKey);
+ };
+ const onKey = (e) => {
+ if (e.key === "Escape") close();
+ };
+
+ const row = document.createElement("div");
+ row.className = "row";
+ const copyBtn = document.createElement("button");
+ copyBtn.className = "secondary";
+ copyBtn.textContent = "Copy link";
+ // In-modal feedback: a toast would render behind this full-screen overlay.
+ copyBtn.onclick = async () => {
+ try {
+ await navigator.clipboard.writeText(link);
+ copyBtn.textContent = "Link copied ✓";
+ } catch {
+ copyBtn.textContent = "Select the link above to copy";
+ }
+ setTimeout(() => (copyBtn.textContent = "Copy link"), 2000);
+ };
+ const doneBtn = document.createElement("button");
+ doneBtn.textContent = "Done";
+ doneBtn.onclick = close;
+ row.appendChild(copyBtn);
+ row.appendChild(doneBtn);
+ card.appendChild(row);
+
+ backdrop.appendChild(card);
+ backdrop.addEventListener("click", (e) => {
+ if (e.target === backdrop) close();
+ });
+ document.addEventListener("keydown", onKey);
+ document.body.appendChild(backdrop);
+}
+
// Generic dismissible modal (used for the "blocked" recovery instructions).
function openModal(innerHTML) {
const backdrop = document.createElement("div");
@@ -316,32 +436,99 @@ function showNoInvite() {
err.classList.remove("hidden");
};
+ // Parse a pasted link into one of two shapes (see docs/invite-redesign.md):
+ // legacy: /join/<token>#k=<key> key carried directly in the fragment
+ // new: /join/<label>#<T>[~<name>] T unwraps a server-stored key; the
+ // optional ~name is a device-add link
+ // that already knows who you are.
const parse = (text) => {
try {
const u = new URL(text.trim());
- const token = u.pathname.split("/").filter(Boolean).pop();
- const key = new URLSearchParams(u.hash.slice(1)).get("k");
- return token && key ? { token, key } : null;
+ const seg = decodeURIComponent(u.pathname.split("/").filter(Boolean).pop() || "");
+ const frag = u.hash.slice(1);
+ if (new URLSearchParams(frag).get("k")) {
+ return { kind: "legacy", token: seg, key: new URLSearchParams(frag).get("k") };
+ }
+ if (!frag) return null;
+ const i = frag.indexOf("~"); // "~" can't appear in T (base64url)
+ const T = i < 0 ? frag : frag.slice(0, i);
+ let name = "";
+ if (i >= 0) {
+ try {
+ name = decodeURIComponent(frag.slice(i + 1));
+ } catch {}
+ }
+ return { kind: "new", T, name, slug: seg };
} catch {
return null;
}
};
- // Pre-fill the screen name from the invite's label, if the field is empty.
- linkInput.addEventListener("change", async () => {
- const p = parse(linkInput.value);
- if (!p || nameInput.value.trim()) return;
+ let joining = false;
+ let autoTried = "";
+
+ // The screen name is a first-time-join thing: keep the field hidden until a
+ // pasted link turns out to need one. A device-add link already knows who you
+ // are, so it never appears for that.
+ function revealName(defaultName) {
+ const wasHidden = nameInput.classList.contains("hidden");
+ if (wasHidden) {
+ nameInput.classList.remove("hidden");
+ const intro = document.getElementById("join-intro");
+ if (intro) intro.textContent = "Almost there — pick a screen name.";
+ }
+ if (defaultName && !nameInput.value.trim()) nameInput.value = defaultName;
+ if (wasHidden) nameInput.focus();
+ }
+
+ // On paste: a device-add link already carries your name, so just join.
+ // A first-time invite (admin link or legacy) reveals the name field instead.
+ const onLink = async () => {
+ const raw = linkInput.value;
+ const p = parse(raw);
+ if (!p) return;
+ if (p.kind === "new" && p.name) {
+ if (joining || autoTried === raw) return;
+ autoTried = raw;
+ err.classList.add("hidden");
+ joining = true;
+ await joinNew(p, p.name);
+ joining = false;
+ return;
+ }
+ if (!nameInput.classList.contains("hidden")) return revealName("");
+ if (p.kind === "new") return revealName(p.slug || "");
+ let label = "";
try {
- const info = await (await fetch(`/api/invite/${encodeURIComponent(p.token)}`)).json();
- if (info.label) nameInput.value = info.label;
+ label = (await (await fetch(`/api/invite/${encodeURIComponent(p.token)}`)).json()).label || "";
} catch {}
- });
+ revealName(label);
+ };
+ linkInput.addEventListener("input", onLink);
+ linkInput.addEventListener("change", onLink);
document.getElementById("paste-join").onsubmit = async (e) => {
e.preventDefault();
err.classList.add("hidden");
const p = parse(linkInput.value);
if (!p) return showErr("That doesn't look like a valid invite link.");
+ if (joining) return;
+ // Device-add link: no name needed — just join.
+ if (p.kind === "new" && p.name) {
+ joining = true;
+ await joinNew(p, p.name);
+ joining = false;
+ return;
+ }
+ // First-time join needs a name; reveal the field before joining.
+ if (nameInput.classList.contains("hidden")) return onLink();
+ joining = true;
+ if (p.kind === "legacy") await joinLegacy(p);
+ else await joinNew(p, nameInput.value);
+ joining = false;
+ };
+
+ async function joinLegacy(p) {
const name = nameInput.value.trim();
if (!name) return showErr("Please enter a screen name.");
try {
@@ -363,7 +550,51 @@ function showNoInvite() {
} catch {}
localStorage.setItem(SESSION_KEY, JSON.stringify({ token: p.token, key: p.key, name }));
location.reload();
- };
+ }
+
+ async function joinNew(p, rawName) {
+ const name = (rawName || "").trim();
+ if (!name) return showErr("Please enter a screen name.");
+ let id, wrapKey;
+ try {
+ ({ id, wrapKey } = await deriveInvite(p.T));
+ } catch {
+ return showErr("This link's secret is malformed.");
+ }
+ let info;
+ try {
+ info = await (await fetch(`/api/key-invite/${encodeURIComponent(id)}`)).json();
+ } catch {
+ return showErr("Couldn't reach the server. Try again.");
+ }
+ if (!info.ok || !info.wrapped) {
+ return showErr("This invite has been used or is no longer valid. Ask for a new one.");
+ }
+ let key;
+ try {
+ key = await unwrapGroupKey(info.wrapped, wrapKey);
+ await importKey(key); // sanity-check the unwrapped key
+ } catch {
+ return showErr("Couldn't unlock this invite (wrong or corrupt secret).");
+ }
+ let claim;
+ try {
+ claim = await (
+ await fetch(`/api/key-invite/${encodeURIComponent(id)}/claim`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ label: name }),
+ })
+ ).json();
+ } catch {
+ return showErr("Couldn't reach the server. Try again.");
+ }
+ if (!claim.ok || !claim.token) {
+ return showErr("This invite was just used. Ask for a new one.");
+ }
+ localStorage.setItem(SESSION_KEY, JSON.stringify({ token: claim.token, key, name }));
+ location.reload();
+ }
}
function loadSession() {
diff --git a/web/static/crypto.js b/web/static/crypto.js
index 7258847..aefa793 100644
--- a/web/static/crypto.js
+++ b/web/static/crypto.js
@@ -77,3 +77,39 @@ export async function decryptBytes(key, bytes) {
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
return new Uint8Array(pt);
}
+
+// ---- invite key-wrapping (see docs/invite-redesign.md) ------------------
+// The invite secret T (base64url) derives a server lookup id + an AES wrapping
+// key via HKDF. The server only ever stores the id and the wrapped key — never
+// T — so it can't recover the group key.
+
+// A fresh 128-bit invite secret.
+export function randomSecret() {
+ return bytesToB64url(crypto.getRandomValues(new Uint8Array(16)));
+}
+
+async function hkdf(tB64, info) {
+ return crypto.subtle.importKey("raw", b64urlToBytes(tB64), "HKDF", false, ["deriveBits", "deriveKey"]).then((ikm) => ({
+ ikm,
+ params: { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode(info) },
+ }));
+}
+
+// deriveInvite returns { id, wrapKey } from the secret T, domain-separated.
+export async function deriveInvite(tB64) {
+ const idd = await hkdf(tB64, "lchat-invite-id");
+ const idBits = await crypto.subtle.deriveBits(idd.params, idd.ikm, 256);
+ const wkd = await hkdf(tB64, "lchat-invite-wrap");
+ const wrapKey = await crypto.subtle.deriveKey(
+ wkd.params, wkd.ikm, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]
+ );
+ return { id: bytesToB64url(new Uint8Array(idBits)), wrapKey };
+}
+
+// wrap/unwrap the group key (base64url) under a derived wrapping CryptoKey.
+export async function wrapGroupKey(groupKeyB64, wrapKey) {
+ return bytesToB64url(await encryptBytes(wrapKey, b64urlToBytes(groupKeyB64)));
+}
+export async function unwrapGroupKey(wrappedB64, wrapKey) {
+ return bytesToB64url(await decryptBytes(wrapKey, b64urlToBytes(wrappedB64)));
+}
diff --git a/web/static/join.js b/web/static/join.js
index 26f1673..2ea6906 100644
--- a/web/static/join.js
+++ b/web/static/join.js
@@ -1,31 +1,116 @@
-// Reads the invite token from the path and the encryption key from the
-// #fragment (which the browser never sends to the server), then lets the user
-// pick a screen name and stashes everything locally.
-import { importKey } from "/static/crypto.js";
+// Handles invite links. Shapes (see docs/invite-redesign.md):
+// legacy: /join/<token>#k=<key> — key carried in the fragment
+// new: /join/<label>#<T> — T (in the fragment) unwraps a
+// server-stored, server-blind key
+// device: /join/<label>#<T>~<name> — same, plus your name, so adding
+// your own device skips the prompt
+// The fragment is never sent to the server in any case.
+import { importKey, deriveInvite, unwrapGroupKey, randomSecret, wrapGroupKey } from "/static/crypto.js";
+import { needsHomeScreen } from "/static/push.js";
const SESSION_KEY = "lchat_session";
const card = document.getElementById("card");
-const token = location.pathname.split("/").filter(Boolean).pop();
-const hash = new URLSearchParams(location.hash.slice(1));
-const key = hash.get("k");
+const isStandalone =
+ window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true;
+
+const seg = decodeURIComponent(location.pathname.split("/").filter(Boolean).pop() || "");
+const params = new URLSearchParams(location.hash.slice(1));
+const legacyKey = params.get("k");
+// New-style fragment: "<T>" or "<T>~<encoded name>" ("~" can't appear in T).
+const rawFrag = legacyKey ? "" : location.hash.slice(1);
+const tilde = rawFrag.indexOf("~");
+const T = tilde < 0 ? rawFrag : rawFrag.slice(0, tilde);
+const fragName = tilde < 0 ? "" : safeDecode(rawFrag.slice(tilde + 1));
init();
-async function init() {
- if (!token || !key) return showError("This link is missing its secret key.");
+function init() {
+ // Already running as the installed app? The link in this URL was the one used
+ // to install it (likely already spent) — don't claim it here. Send them to the
+ // root, where the app signs in via paste-to-join.
+ if (isStandalone) return location.replace("/");
+ if (!legacyKey && !T) return showError("This link is missing its secret.");
+ if (legacyKey) return initLegacy();
+ return initNew();
+}
+
+// After a successful claim: store the session, then either open the chat or — on
+// iOS Safari, where notifications need a Home Screen app with its own storage —
+// guide the Home Screen setup (mint a fresh link to paste inside the app).
+function finishJoin(session) {
+ localStorage.setItem(SESSION_KEY, JSON.stringify(session));
+ if (needsHomeScreen()) return showHomeScreenSetup(session);
+ location.href = "/"; // drop the fragment from the URL bar
+}
- // The key must be a valid AES-GCM key before we trust the link.
+// ---- new: T unwraps a server-stored wrapped key -------------------------
+
+async function initNew() {
+ let id, wrapKey;
try {
- await importKey(key);
+ ({ id, wrapKey } = await deriveInvite(T));
} catch {
- return showError("This link's secret key is malformed.");
+ return showError("This link's secret is malformed.");
+ }
+ let info;
+ try {
+ info = await (await fetch(`/api/key-invite/${encodeURIComponent(id)}`)).json();
+ } catch {
+ return showError("Couldn't reach the server. Try again.");
}
+ if (!info.ok || !info.wrapped) {
+ return showError("This invite has been used or is no longer valid. Ask for a new one.");
+ }
+ let groupKey;
+ try {
+ groupKey = await unwrapGroupKey(info.wrapped, wrapKey);
+ await importKey(groupKey); // sanity-check the unwrapped key
+ } catch {
+ return showError("Couldn't unlock this invite (wrong or corrupt secret).");
+ }
+
+ const claimAndGo = async (name) => {
+ let claim;
+ try {
+ claim = await (
+ await fetch(`/api/key-invite/${encodeURIComponent(id)}/claim`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ label: name }),
+ })
+ ).json();
+ } catch {
+ return showError("Couldn't reach the server. Try again.");
+ }
+ if (!claim.ok || !claim.token) {
+ return showError("This invite was just used. Ask for a new one.");
+ }
+ // token is now a device token (ongoing auth); key is the unwrapped group key.
+ finishJoin({ token: claim.token, key: groupKey, name });
+ };
+
+ // A device-add link already carries your name — just join, no prompt.
+ if (fragName) {
+ showStatus("Signing you in…");
+ return claimAndGo(fragName);
+ }
+ showForm(seg, claimAndGo);
+}
+// ---- legacy: key carried directly in the #fragment ----------------------
+
+async function initLegacy() {
+ const token = seg;
+ if (!token || !legacyKey) return showError("This link is missing its secret key.");
+ try {
+ await importKey(legacyKey);
+ } catch {
+ return showError("This link's secret key is malformed.");
+ }
let info;
try {
- const res = await fetch(`/api/invite/${encodeURIComponent(token)}`);
- info = await res.json();
+ info = await (await fetch(`/api/invite/${encodeURIComponent(token)}`)).json();
} catch {
return showError("Couldn't reach the server. Try again.");
}
@@ -36,32 +121,101 @@ async function init() {
: "This invite link is invalid. Ask for a new one."
);
}
+ showForm(info.label || "", async (name) => {
+ try {
+ await fetch(`/api/invite/${encodeURIComponent(token)}/claim`, { method: "POST" });
+ } catch {}
+ finishJoin({ token, key: legacyKey, name });
+ });
+}
+
+// ---- iOS Safari: set up the Home Screen app -----------------------------
+
+// Mint a fresh single-use device link tied to this member, for them to paste
+// inside the installed app. The browser wraps the key it already holds, so the
+// server stays blind. Mirrors chat.js's "Add a device".
+async function mintDeviceLink(session) {
+ const t = randomSecret();
+ const { id, wrapKey } = await deriveInvite(t);
+ const wrapped = await wrapGroupKey(session.key, wrapKey);
+ const res = await fetch("/api/device-invites", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, label: session.name, id, wrapped }),
+ });
+ if (!res.ok) throw new Error("mint failed");
+ const slug =
+ (session.name || "device").trim().replace(/[^\w]+/g, "-").replace(/^-+|-+$/g, "") || "device";
+ return `${location.origin}/join/${encodeURIComponent(slug)}#${t}~${encodeURIComponent(session.name)}`;
+}
+
+async function showHomeScreenSetup(session) {
+ showStatus("Setting up…");
+ let link = "";
+ try {
+ link = await mintDeviceLink(session);
+ } catch {
+ // Couldn't mint — they can still use Safari, or "Add a device" from the app menu.
+ }
+
+ card.classList.add("onboard");
+ card.innerHTML = "";
+ card.appendChild(document.getElementById("homescreen-tpl").content.cloneNode(true));
+
+ const copy = card.querySelector("#hs-copy");
+ if (link) {
+ copy.onclick = async () => {
+ try {
+ await navigator.clipboard.writeText(link);
+ copy.textContent = "Link copied ✓";
+ setTimeout(() => (copy.textContent = "Copy sign-in link"), 2500);
+ } catch {
+ copy.textContent = "Press and hold to copy, or use “Add a device” in the app";
+ }
+ };
+ } else {
+ copy.disabled = true;
+ copy.textContent = "Couldn't make a link — use “Add a device” in the app";
+ }
- showForm(info.label || "");
+ card.querySelector("#hs-open").onclick = () => location.assign("/");
}
-function showForm(label) {
+// ---- shared name form ---------------------------------------------------
+
+function showForm(defaultName, onSubmit) {
card.innerHTML = "";
card.appendChild(document.getElementById("form-tpl").content.cloneNode(true));
const input = card.querySelector("#name");
- if (label) input.value = label; // pre-fill from the invite's label
+ if (defaultName) input.value = defaultName; // pre-fill from the invite's label
input.focus();
input.select();
- card.querySelector("#join-form").onsubmit = async (e) => {
+ card.querySelector("#join-form").onsubmit = (e) => {
e.preventDefault();
const name = input.value.trim();
- if (!name) return;
- try {
- await fetch(`/api/invite/${encodeURIComponent(token)}/claim`, { method: "POST" });
- } catch {}
- localStorage.setItem(SESSION_KEY, JSON.stringify({ token, key, name }));
- // Redirect WITHOUT the fragment so the key isn't left in the URL bar.
- location.href = "/";
+ if (name) onSubmit(name);
};
}
+function showStatus(msg) {
+ card.innerHTML = "";
+ const p = document.createElement("p");
+ p.className = "sub";
+ p.textContent = msg;
+ card.appendChild(p);
+}
+
function showError(msg) {
+ card.classList.remove("onboard");
card.innerHTML = "";
card.appendChild(document.getElementById("error-tpl").content.cloneNode(true));
card.querySelector("#error-msg").textContent = msg;
}
+
+function safeDecode(s) {
+ try {
+ return decodeURIComponent(s);
+ } catch {
+ return "";
+ }
+}
diff --git a/web/static/style.css b/web/static/style.css
index fe2691e..aea420e 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -712,6 +712,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
background: var(--ink); color: #fff; padding: 10px 16px; border-radius: 999px;
font-size: 14px; opacity: 0; transition: opacity .2s; pointer-events: none;
+ z-index: 100; /* above full-screen overlays (qr-screen) so it's never hidden */
}
.toast.show { opacity: 1; }