server+web+native: sealed branding under a link-carried key — #724 step 4 (#731)
* server+web+native: sealed branding under a link-carried branding key Branding (name, description, photo + alt, admin identity) was plaintext by design because it must render where no member key exists — but every such surface is reached via a link, and links have a server-blind channel: the #fragment. Sealing (opt-in, Admin app → Privacy) moves branding into one AES-GCM blob under a key derived (PBKDF2, 600k) from a short branding key B that rides every minted link as the third fragment slot (e1.<T>~<name>~<B>); members bootstrap B from a copy sealed under the instance key. The server stores and serves only ciphertext, refuses plaintext branding writes while sealed (409), and the join card decrypts name/description/photo pre-claim. Old links keep claiming (T is slot-fixed first); old natives claim new links (their parser cuts at the first ~) — this change also teaches native the 3-slot form (suggestedName stops before a key-shaped tail; brandingKey exposed for the follow-up rendering path) and aligns its builder's ~ escaping with the web. Honest scope in docs/no-plaintext.md: B defeats bulk exposure of a leaked DB/bucket, not a targeted brute-force; member content was never under B. Part of #724, step 4 of docs/no-plaintext.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * server+web+native: close the review holes in sealed branding Pre-merge review findings, all fixed with tests: - avatar_alt hid in the settings grab-bag ungated — now 409s while sealed like every other plaintext branding setter (the one invariant-breaker). - Sealing with no sealed replacement photo now CLEARS an existing plaintext photo instead of leaving readable bytes served behind the sealed flag; sealBrandingNow aborts if its avatar fetch fails (retryable, loses nothing), and text-only re-seals keep an already-sealed photo. - branding_blob/salt move off the fully-public /api/group read onto the member read — bare visitors could otherwise fetch ciphertext to brute-force, and any past link-holder could open the members-only admin identity. Invite reads (unguessable id) are unchanged; no consumer read the public copy. - Stale cached B (after unseal→re-seal) no longer bricks branding on other devices: decrypt failure falls back to the instance-key copy and repairs the localStorage cache. - Config writes ordered with the sealed flag last (seal) / first-restored- plaintext (unseal) so a mid-loop failure never strands sealed=1 beside readable plaintext. - Photo changes now WORK while sealed: pick/adopt route through a re-seal (bytes encrypted under B), and the Admin app displays the sealed photo via a decrypting bridge URL instead of a broken ciphertext <img>. - Join card: the h1 headline now shows the decrypted name (it only set the tab title); the B handoff happens before the photo decrypt so a photo failure can't cost the key; photo decrypt failure shows the placeholder. - Native (LChatCore): a sealed group read degrades to unnamed + photoless (was: the literal compiled-in default name + ciphertext avatar bytes); serverAvatar skips the sealed content type. Full rendering stays the follow-up; the Admin toggle + changelog now say so honestly. - Pinned KDF vector in test/crypto.test.mjs freezes PBKDF2 params — a change would silently brick sealed blobs at rest. - resealBranding mirrors the 1–60 name rule; docs/no-plaintext.md drift fixed (invitee-name slot, home follow-up, per-load derivation, rollout order). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
17 files changed,
+1102
−58
branding.go+175 −0branding_test.go+222 −0changelog/2026-07-11-sealed-branding.md+9 −0db.go+5 −1docs/no-plaintext.md+21 −12ios/LChatCore/Sources/LChatCore/InviteCrypto.swift+3 −1ios/LChatCore/Sources/LChatCore/LChatAPI.swift+13 −3ios/LChatCore/Sources/LChatCore/SpaceCreate.swift+27 −2ios/LChatCore/Tests/LChatCoreTests/SpaceCreateTests.swift+20 −0main.go+54 −1test/crypto.test.mjs+35 −0test/invite.test.mjs+47 −12web/static/apps/admin.js+52 −2web/static/crypto.js+40 −0web/static/invite.js+30 −10web/static/join.js+60 −10web/static/shell.js+289 −4
diff --git a/branding.go b/branding.go| new file mode 100644 |
| index 0000000..1e18126 |
| --- /dev/null |
| +++ b/branding.go |
| @@ -0,0 +1,175 @@ |
| +package main |
| + |
| +// Sealed instance branding under the link-carried branding key |
| +// (docs/no-plaintext.md). Branding — group name, description, admin identity, |
| +// the server avatar and its alt — must render on surfaces where no member key |
| +// exists yet (join cards, native add-chat, the home save card). Every one of |
| +// those surfaces is reached via a link, so branding can be sealed under a key |
| +// B that rides the link #fragment (never sent to the server): clients derive |
| +// an AES key from B (PBKDF2, see crypto.js deriveBrandingKey) and open one |
| +// blob. Members bootstrap B from a copy sealed under the instance key, so |
| +// nobody needs to re-visit a link. |
| +// |
| +// The server's role here is storage and gating only: it never sees B, never |
| +// derives, never opens. All ciphertext below is produced client-side by an |
| +// admin who holds both admin authority and the instance key. Sealing is |
| +// opt-in per instance and reversible (the client supplies the plaintext back |
| +// on unseal — the server cannot). |
| +// |
| +// Honest scope (also in the doc): B is short and the ciphertext sits on this |
| +// very server, so sealing defeats bulk exposure of a leaked DB or bucket, not |
| +// a targeted brute-force by the operator. Member content is never under B. |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "time" |
| +) |
| + |
| +// sealedAvatarMime marks instance_avatar bytes as branding-key ciphertext |
| +// (the base64url iv||ct string, stored as bytes). Clients that see this |
| +// content type fetch the body as text and decrypt with the branding key. |
| +const sealedAvatarMime = "application/vnd.lchat.sealed" |
| + |
| +func (db *DB) brandingSealed() bool { |
| + v, _, _ := db.getConfig("branding_sealed") |
| + return v == "1" |
| +} |
| + |
| +// sealBranding stores the client-sealed branding set and clears every |
| +// plaintext counterpart in the same request, flipping the instance to sealed |
| +// mode. Idempotent: re-posting (a rename, a new photo) overwrites the blobs. |
| +func (s *Server) sealBranding(w http.ResponseWriter, r *http.Request) { |
| + if !s.requireAdmin(w, r) { |
| + return |
| + } |
| + var body struct { |
| + // AES-GCM over {name, description, admin_name, admin_email, |
| + // avatar_alt, avatar_mime} under the B-derived key. |
| + BrandingBlob string `json:"branding_blob"` |
| + // PBKDF2 salt for deriving from B — random, not secret. |
| + BrandingSalt string `json:"branding_salt"` |
| + // B sealed under the instance key, for member bootstrap. |
| + BrandingKeySealed string `json:"branding_key_sealed"` |
| + // Optional: the avatar bytes sealed under the B-derived key |
| + // (base64url iv||ct). Empty keeps the current avatar state. |
| + AvatarSealed string `json:"avatar_sealed"` |
| + } |
| + if json.NewDecoder(r.Body).Decode(&body) != nil { |
| + httpError(w, http.StatusBadRequest, "bad request") |
| + return |
| + } |
| + if body.BrandingBlob == "" || len(body.BrandingBlob) > 16*1024 || |
| + body.BrandingSalt == "" || len(body.BrandingSalt) > 128 || |
| + body.BrandingKeySealed == "" || len(body.BrandingKeySealed) > 1024 || |
| + len(body.AvatarSealed) > 2*maxServerAvatarBytes { |
| + httpError(w, http.StatusBadRequest, "bad request") |
| + return |
| + } |
| + // The avatar first: a sealed instance must never keep serving plaintext |
| + // photo bytes. If the client sends no sealed replacement (it had none, or |
| + // its avatar fetch failed mid-seal), an existing PLAINTEXT photo is |
| + // cleared rather than left readable behind a "sealed" flag — the admin |
| + // re-seals with the photo to bring it back. An already-sealed avatar is |
| + // kept (re-seals that only change text omit it). |
| + if body.AvatarSealed != "" { |
| + if err := s.db.setInstanceAvatar([]byte(body.AvatarSealed), sealedAvatarMime, time.Now().Unix()); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + } else if _, mime, _, ok := s.db.getInstanceAvatar(); ok && mime != sealedAvatarMime { |
| + if err := s.db.clearInstanceAvatar(); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + } |
| + // Ordered, sealed flag LAST: these writes aren't one transaction, so a |
| + // mid-loop failure must never leave branding_sealed=1 beside readable |
| + // plaintext — flipping the flag is the final act. |
| + for _, kv := range [][2]string{ |
| + {"branding_blob", body.BrandingBlob}, |
| + {"branding_salt", body.BrandingSalt}, |
| + {"branding_key_sealed", body.BrandingKeySealed}, |
| + // Clear the plaintext the blob replaces. group_name intentionally |
| + // falls back to the compiled-in default for keyless surfaces; |
| + // groupNameSet() reports true while sealed (see db.go). |
| + {"group_name", ""}, |
| + {"group_description", ""}, |
| + {"server_avatar_alt", ""}, |
| + {"admin_name", ""}, |
| + {"admin_email", ""}, |
| + {"branding_sealed", "1"}, |
| + } { |
| + if err := s.db.setConfig(kv[0], kv[1]); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| +} |
| + |
| +// unsealBranding restores plaintext mode. The server cannot decrypt, so the |
| +// admin's client (which just decrypted the blob) supplies the plaintext back. |
| +// The sealed avatar bytes are useless in plaintext mode and are cleared; the |
| +// admin re-uploads the photo via the normal setServerAvatar path. |
| +func (s *Server) unsealBranding(w http.ResponseWriter, r *http.Request) { |
| + if !s.requireAdmin(w, r) { |
| + return |
| + } |
| + if !s.db.brandingSealed() { |
| + httpError(w, http.StatusConflict, "branding is not sealed") |
| + return |
| + } |
| + var body struct { |
| + Name string `json:"name"` |
| + Description string `json:"description"` |
| + AdminName string `json:"admin_name"` |
| + AdminEmail string `json:"admin_email"` |
| + AvatarAlt string `json:"avatar_alt"` |
| + } |
| + if json.NewDecoder(r.Body).Decode(&body) != nil { |
| + httpError(w, http.StatusBadRequest, "bad request") |
| + return |
| + } |
| + if body.Name == "" || len(body.Name) > 60 { |
| + httpError(w, http.StatusBadRequest, "name must be 1–60 characters") |
| + return |
| + } |
| + // Ordered, sealed flag LAST (mirror of sealBranding): restore the |
| + // plaintext, drop the blobs, and only then leave sealed mode — a mid-loop |
| + // failure keeps the instance sealed and the unseal retryable. |
| + for _, kv := range [][2]string{ |
| + {"group_name", body.Name}, |
| + {"group_description", body.Description}, |
| + {"server_avatar_alt", body.AvatarAlt}, |
| + {"admin_name", body.AdminName}, |
| + {"admin_email", body.AdminEmail}, |
| + {"branding_blob", ""}, |
| + {"branding_salt", ""}, |
| + {"branding_key_sealed", ""}, |
| + {"branding_sealed", ""}, |
| + } { |
| + if err := s.db.setConfig(kv[0], kv[1]); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + } |
| + if _, mime, _, ok := s.db.getInstanceAvatar(); ok && mime == sealedAvatarMime { |
| + if err := s.db.clearInstanceAvatar(); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| +} |
| + |
| +// requireUnsealed guards the plaintext branding setters: while sealed, a |
| +// stale admin surface must not write readable branding back beside the blob. |
| +// Renames and new photos go through sealBranding again (the client re-seals). |
| +func (s *Server) requireUnsealed(w http.ResponseWriter) bool { |
| + if s.db.brandingSealed() { |
| + httpError(w, http.StatusConflict, "branding is sealed — update it via the sealed-branding flow") |
| + return false |
| + } |
| + return true |
| +} |
diff --git a/branding_test.go b/branding_test.go| new file mode 100644 |
| index 0000000..9ed1f03 |
| --- /dev/null |
| +++ b/branding_test.go |
| @@ -0,0 +1,222 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "net/http" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func sealTestBranding(t *testing.T, ts string) { |
| + t.Helper() |
| + body, _ := json.Marshal(map[string]string{ |
| + "branding_blob": "SEALED(branding)", |
| + "branding_salt": "salt123", |
| + "branding_key_sealed": "WRAPPED(B)", |
| + "avatar_sealed": "SEALED(avatar-bytes)", |
| + }) |
| + r := adminReq(t, "POST", ts+"/api/admin/branding/seal", "application/json", body) |
| + if r.StatusCode != http.StatusOK { |
| + t.Fatalf("seal: %d", r.StatusCode) |
| + } |
| +} |
| + |
| +// Sealing stores only ciphertext and clears every plaintext counterpart; the |
| +// public read serves the blob (openable only by link-holders), the member read |
| +// adds the instance-key-sealed copy of B, and keyless surfaces fall back to |
| +// the compiled-in default name. |
| +func TestBrandingSeal(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + |
| + // Start named + branded in plaintext, as a real instance would. |
| + srv.db.setGroupName("Family Chat") |
| + srv.db.setGroupDescription("Our cosy corner") |
| + srv.db.setAdminName("Paul") |
| + srv.db.setAdminEmail("paul@example.com") |
| + srv.db.setConfig("server_avatar_alt", "A sunflower") |
| + |
| + sealTestBranding(t, ts.URL) |
| + |
| + // No plaintext branding remains anywhere in config. |
| + rows, err := srv.db.sql.Query("SELECT key, value FROM config") |
| + if err != nil { |
| + t.Fatalf("config: %v", err) |
| + } |
| + defer rows.Close() |
| + for rows.Next() { |
| + var k, v string |
| + rows.Scan(&k, &v) |
| + for _, secret := range []string{"Family Chat", "cosy", "Paul", "paul@example.com", "sunflower"} { |
| + if strings.Contains(v, secret) { |
| + t.Fatalf("plaintext %q survives sealing in config[%s]", secret, k) |
| + } |
| + } |
| + } |
| + |
| + // Public read: only the sealed marker — no ciphertext for bare visitors |
| + // (serving it would let anyone start the offline brute-force, and any past |
| + // link-holder open the members-only admin identity). Name fell back to the |
| + // default; name_set stays true (the space IS named — just not readably). |
| + r, _ := http.Get(ts.URL + "/api/group") |
| + var pub map[string]any |
| + json.NewDecoder(r.Body).Decode(&pub) |
| + if pub["sealed"] != true { |
| + t.Fatalf("public group read = %v", pub) |
| + } |
| + if pub["name"] == "Family Chat" || pub["name_set"] != true { |
| + t.Fatalf("sealed name leak / name_set: %v", pub) |
| + } |
| + for _, k := range []string{"branding_blob", "branding_salt", "branding_key_sealed"} { |
| + if _, leak := pub[k]; leak { |
| + t.Fatalf("%s served on the public read", k) |
| + } |
| + } |
| + |
| + // Member read: the blob + salt, plus the sealed B for bootstrap. |
| + tok := mintDeviceToken(t, srv.db, "m") |
| + r, _ = http.Get(ts.URL + "/api/group?token=" + tok) |
| + var mem map[string]any |
| + json.NewDecoder(r.Body).Decode(&mem) |
| + if mem["branding_blob"] != "SEALED(branding)" || mem["branding_salt"] != "salt123" || mem["branding_key_sealed"] != "WRAPPED(B)" { |
| + t.Fatalf("member group read = %v", mem) |
| + } |
| + |
| + // The avatar serves the sealed bytes under the sealed content type. |
| + r, _ = http.Get(ts.URL + "/api/server-avatar") |
| + if ct := r.Header.Get("Content-Type"); ct != sealedAvatarMime { |
| + t.Fatalf("avatar content-type = %q", ct) |
| + } |
| +} |
| + |
| +// While sealed, the plaintext setters refuse — a stale admin surface must not |
| +// write readable branding back beside the blob. |
| +func TestBrandingSealGuardsPlaintextSetters(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + srv.db.setGroupName("Before") |
| + sealTestBranding(t, ts.URL) |
| + |
| + name, _ := json.Marshal(map[string]string{"name": "Plain Again"}) |
| + if r := adminReq(t, "POST", ts.URL+"/api/admin/group", "application/json", name); r.StatusCode != http.StatusConflict { |
| + t.Fatalf("setGroup while sealed: want 409, got %d", r.StatusCode) |
| + } |
| + desc, _ := json.Marshal(map[string]string{"description": "readable"}) |
| + if r := adminReq(t, "POST", ts.URL+"/api/group-description", "application/json", desc); r.StatusCode != http.StatusConflict { |
| + t.Fatalf("description while sealed: want 409, got %d", r.StatusCode) |
| + } |
| + if r := adminReq(t, "POST", ts.URL+"/api/admin/server-avatar", "image/png", []byte("png-bytes")); r.StatusCode != http.StatusConflict { |
| + t.Fatalf("avatar while sealed: want 409, got %d", r.StatusCode) |
| + } |
| + // avatar_alt hides in the settings grab-bag — it must 409 like the rest. |
| + alt, _ := json.Marshal(map[string]string{"avatar_alt": "readable alt"}) |
| + if r := adminReq(t, "POST", ts.URL+"/api/admin/settings", "application/json", alt); r.StatusCode != http.StatusConflict { |
| + t.Fatalf("avatar_alt while sealed: want 409, got %d", r.StatusCode) |
| + } |
| + // Non-branding settings still work while sealed. |
| + gate, _ := json.Marshal(map[string]bool{"apps_enabled": true}) |
| + if r := adminReq(t, "POST", ts.URL+"/api/admin/settings", "application/json", gate); r.StatusCode != http.StatusOK { |
| + t.Fatalf("non-branding settings while sealed: want 200, got %d", r.StatusCode) |
| + } |
| +} |
| + |
| +// Sealing with no sealed replacement photo must not leave an existing |
| +// PLAINTEXT photo publicly served behind the sealed flag (the client omits |
| +// avatar_sealed when it has no photo — or when its avatar fetch failed |
| +// mid-seal). A re-seal that omits it keeps an already-sealed photo. |
| +func TestBrandingSealClearsPlaintextAvatar(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + if err := srv.db.setInstanceAvatar([]byte("real-jpeg-bytes"), "image/jpeg", 1); err != nil { |
| + t.Fatalf("seed avatar: %v", err) |
| + } |
| + |
| + body, _ := json.Marshal(map[string]string{ |
| + "branding_blob": "SEALED(branding)", "branding_salt": "salt123", "branding_key_sealed": "WRAPPED(B)", |
| + }) |
| + if r := adminReq(t, "POST", ts.URL+"/api/admin/branding/seal", "application/json", body); r.StatusCode != http.StatusOK { |
| + t.Fatalf("seal: %d", r.StatusCode) |
| + } |
| + if _, _, _, ok := srv.db.getInstanceAvatar(); ok { |
| + t.Fatal("plaintext photo still served on a sealed instance") |
| + } |
| + |
| + // Re-seal with a sealed photo, then a text-only re-seal: the photo stays. |
| + body, _ = json.Marshal(map[string]string{ |
| + "branding_blob": "SEALED(branding2)", "branding_salt": "salt123", "branding_key_sealed": "WRAPPED(B)", |
| + "avatar_sealed": "SEALED(avatar-bytes)", |
| + }) |
| + if r := adminReq(t, "POST", ts.URL+"/api/admin/branding/seal", "application/json", body); r.StatusCode != http.StatusOK { |
| + t.Fatalf("re-seal with photo: %d", r.StatusCode) |
| + } |
| + body, _ = json.Marshal(map[string]string{ |
| + "branding_blob": "SEALED(branding3)", "branding_salt": "salt123", "branding_key_sealed": "WRAPPED(B)", |
| + }) |
| + if r := adminReq(t, "POST", ts.URL+"/api/admin/branding/seal", "application/json", body); r.StatusCode != http.StatusOK { |
| + t.Fatalf("text-only re-seal: %d", r.StatusCode) |
| + } |
| + if _, mime, _, ok := srv.db.getInstanceAvatar(); !ok || mime != sealedAvatarMime { |
| + t.Fatalf("sealed photo lost on a text-only re-seal (ok=%v mime=%q)", ok, mime) |
| + } |
| +} |
| + |
| +// Invite reads carry the blob + salt so the join card (whose link fragment |
| +// carries B) renders full branding before any key exists. |
| +func TestBrandingOnKeyInvite(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + sealTestBranding(t, ts.URL) |
| + |
| + uid, _ := srv.db.createUser("") |
| + if err := srv.db.createKeyInvite("inv-brand", uid, "", testClaimHash, 0); err != nil { |
| + t.Fatalf("createKeyInvite: %v", err) |
| + } |
| + r, _ := http.Get(ts.URL + "/api/key-invite/inv-brand") |
| + var resp map[string]any |
| + json.NewDecoder(r.Body).Decode(&resp) |
| + if resp["ok"] != true || resp["branding_blob"] != "SEALED(branding)" || resp["branding_salt"] != "salt123" { |
| + t.Fatalf("key-invite read = %v", resp) |
| + } |
| +} |
| + |
| +// Unseal restores plaintext mode from client-supplied values (the server |
| +// cannot decrypt) and clears the sealed rows + the now-useless sealed avatar. |
| +func TestBrandingUnseal(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + sealTestBranding(t, ts.URL) |
| + |
| + body, _ := json.Marshal(map[string]string{ |
| + "name": "Family Chat", "description": "Back in the clear", |
| + "admin_name": "Paul", "admin_email": "paul@example.com", "avatar_alt": "", |
| + }) |
| + if r := adminReq(t, "POST", ts.URL+"/api/admin/branding/unseal", "application/json", body); r.StatusCode != http.StatusOK { |
| + t.Fatalf("unseal: %d", r.StatusCode) |
| + } |
| + if srv.db.brandingSealed() { |
| + t.Fatal("still sealed after unseal") |
| + } |
| + if got := srv.db.getGroupName(); got != "Family Chat" { |
| + t.Fatalf("name after unseal = %q", got) |
| + } |
| + if _, _, _, ok := srv.db.getInstanceAvatar(); ok { |
| + t.Fatal("sealed avatar bytes survived unseal") |
| + } |
| + // Unsealing twice conflicts (nothing to unseal). |
| + if r := adminReq(t, "POST", ts.URL+"/api/admin/branding/unseal", "application/json", body); r.StatusCode != http.StatusConflict { |
| + t.Fatalf("double unseal: want 409, got %d", r.StatusCode) |
| + } |
| +} |
| + |
| +// Sealing is admin-only. |
| +func TestBrandingSealRequiresAdmin(t *testing.T) { |
| + ts, _ := newTestServer(t) |
| + body, _ := json.Marshal(map[string]string{ |
| + "branding_blob": "x", "branding_salt": "y", "branding_key_sealed": "z", |
| + }) |
| + req, _ := http.NewRequest("POST", ts.URL+"/api/admin/branding/seal", bytes.NewReader(body)) |
| + req.Header.Set("Authorization", "Bearer wrong") |
| + r, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("do: %v", err) |
| + } |
| + if r.StatusCode == http.StatusOK { |
| + t.Fatal("non-admin sealed branding") |
| + } |
| +} |
diff --git a/changelog/2026-07-11-sealed-branding.md b/changelog/2026-07-11-sealed-branding.md| new file mode 100644 |
| index 0000000..acc83a2 |
| --- /dev/null |
| +++ b/changelog/2026-07-11-sealed-branding.md |
| @@ -0,0 +1,9 @@ |
| +🤖 Admins can now **seal the space's branding** — the space's name, photo, |
| +description, and admin contact move into a locked box on the server, and the |
| +key rides inside your invite links instead of ever being sent to the server. |
| +People you invite still see everything (their link carries the key); the |
| +people who run the servers see only the locked box. Find it under **Privacy** |
| +in the Admin app. Messages, photos, and files people share were always locked |
| +this way — this closes the last readable corner. One honest note while it's |
| +new: the iPhone and Mac apps can't open the box yet, so a sealed space shows |
| +there with a generic name and no photo — that catches up in a coming update. |
diff --git a/db.go b/db.go| index 265cb50..ad00839 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -35,8 +35,12 @@ func (db *DB) setGroupName(name string) error { |
| // groupNameSet reports whether an admin explicitly named the instance (vs the |
| // compiled-in default) — the setup conversation asks fresh instead of offering |
| -// a default to keep. |
| +// a default to keep. A sealed instance is always named: the name lives in the |
| +// branding blob and the plaintext column is deliberately empty. |
| func (db *DB) groupNameSet() bool { |
| + if db.brandingSealed() { |
| + return true |
| + } |
| v, ok, _ := db.getConfig("group_name") |
| return ok && strings.TrimSpace(v) != "" |
| } |
diff --git a/docs/no-plaintext.md b/docs/no-plaintext.md| index b76a88f..85bd816 100644 |
| --- a/docs/no-plaintext.md |
| +++ b/docs/no-plaintext.md |
| @@ -102,8 +102,10 @@ A per-instance symmetric key one trust rung below the instance key: |
| one stretching KDF native to both WebCrypto and CommonCrypto — Argon2id |
| would be strictly better against GPUs but needs WASM in the browser, and |
| the frontend has a no-build-step rule. The derived key is AES-GCM-256; |
| - clients cache it (localStorage / Keychain) so the ~0.5 s derivation is paid |
| - once per device, not per load. |
| + clients cache **B** (localStorage / Keychain) and re-derive on boot — a |
| + sub-second, once-per-load cost. A pinned test vector |
| + (`test/crypto.test.mjs`) freezes the parameters: changing them would brick |
| + every sealed blob already at rest. |
| ### What gets sealed |
| @@ -118,14 +120,17 @@ support has shipped) — so the fleet is safe at every intermediate point. |
| - **Invite / setup / add-device links**: the fragment becomes |
| `#<T>~<name>~<B>` — T first, so *old* parsers (web and native) still |
| - extract T and can claim; they merely can't decrypt branding. Once an |
| - instance seals, the plaintext `~<name>` slot is left empty (`#<T>~~<B>`) — |
| - the whole point is that links stop carrying readable names. |
| + extract T and can claim; they merely can't decrypt branding. The `~<name>` |
| + slot is the *invitee's* name (a prefill), not the space's, and fragments |
| + never reach the server — so it stays populated on sealed instances; B is |
| + simply appended as a third slot. |
| - **Members**: cached client-side on first use; additionally one copy of B is |
| sealed under the **instance key** (`config.branding_key_sealed`), so every |
| existing member's client can bootstrap B without ever re-visiting a link. |
| -- **Home**: the saved-space vault entry (already client-encrypted) gains B, |
| - so the Save-passkey card renders sealed branding. |
| +- **Home** (follow-up, not yet wired): the saved-space vault entry (already |
| + client-encrypted) gains B so the Save-passkey card can render the photo |
| + too. Until then the enrol handoff already carries the decrypted name in |
| + its fragment, so home shows the right name either way. |
| - **The only orphan**: typing the bare domain with no link and no session — |
| renders a generic unbranded page. That is a privacy improvement, not a |
| regression: an unauthenticated visitor learns nothing, not even the name. |
| @@ -154,11 +159,15 @@ servers are never sent one."* |
| ### Compatibility & rollout order |
| -1. Native apps learn the three-slot fragment and the sealed-branding |
| - rendering path (iOS first). Old links keep working forever (T parse is |
| - position-stable). New links work on old natives for *claiming*; only the |
| - pre-claim branding display degrades — acceptable because sealing is |
| - opt-in and ships after native support. |
| +1. Native apps learn the three-slot fragment, and degrade *gracefully* on a |
| + sealed instance (a `sealed` group read renders as unnamed + photoless — |
| + never the compiled-in default or ciphertext bytes). Old links keep |
| + working forever (T parse is position-stable). New links work on old |
| + natives for *claiming*; only the pre-claim branding display degrades. |
| + Full native sealed-branding **rendering** (CommonCrypto PBKDF2 against |
| + the pinned vector, B in the Keychain) is the follow-up — until it lands, |
| + sealing an instance costs native surfaces the name and photo, a stated |
| + trade the opt-in admin makes. |
| 2. Server + web land behind the opt-in. Canary seals; fleet unaffected. |
| 3. New-space setup seals by default; existing admins get a console nudge. |
| 4. Renaming re-seals under the same B. B rotates only with a deliberate |
diff --git a/ios/LChatCore/Sources/LChatCore/InviteCrypto.swift b/ios/LChatCore/Sources/LChatCore/InviteCrypto.swift| index 01b712f..eeae1cd 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/InviteCrypto.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/InviteCrypto.swift |
| @@ -39,9 +39,11 @@ public enum InviteCrypto { |
| } |
| /// invite.js buildInviteFragment: "e1.<T>" or "e1.<T>~<encoded name>". |
| + /// "~" is escaped in the name (unlike encodeURIComponent) so it can never |
| + /// collide with the slot separator — the web builder does the same. |
| public static func fragment(_ tB64: String, name: String) -> String { |
| var allowed = CharacterSet.alphanumerics |
| - allowed.insert(charactersIn: "-_.!~*'()") // encodeURIComponent's survivors |
| + allowed.insert(charactersIn: "-_.!*'()") // encodeURIComponent's survivors, minus "~" |
| let encoded = name.addingPercentEncoding(withAllowedCharacters: allowed) ?? "" |
| return "e1." + tB64 + (encoded.isEmpty ? "" : "~" + encoded) |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift| index 2836b35..adc8264 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift |
| @@ -358,8 +358,12 @@ public struct LChatAPI { |
| /// even literally "lchat" — always shows); the literal check is only the |
| /// degraded path for older servers that don't send the flag. |
| public func groupName() async -> String { |
| - struct Resp: Codable { let name: String?; let name_set: Bool? } |
| + struct Resp: Codable { let name: String?; let name_set: Bool?; let sealed: Bool? } |
| guard let r = try? await getJSON("/api/group", Resp.self, authed: false) else { return "" } |
| + // Sealed branding (docs/no-plaintext.md): the readable name lives in a |
| + // blob this build can't open yet — degrade to "" so callers use their |
| + // host fallback rather than parroting the server's compiled-in default. |
| + if r.sealed == true { return "" } |
| return Self.chosenName(r.name, nameSet: r.name_set) |
| } |
| @@ -367,8 +371,11 @@ public struct LChatAPI { |
| /// see groupName) plus whether a server photo is set. Both are public |
| /// (shown during add-a-chat, before any key exists). |
| public func serverInfo() async -> (name: String, hasAvatar: Bool) { |
| - struct Resp: Codable { let name: String?; let name_set: Bool?; let avatar: Bool? } |
| + struct Resp: Codable { let name: String?; let name_set: Bool?; let avatar: Bool?; let sealed: Bool? } |
| let r = try? await getJSON("/api/group", Resp.self, authed: false) |
| + // Sealed: no readable name, and the photo endpoint serves ciphertext — |
| + // render as unnamed and photoless until sealed-branding display lands. |
| + if r?.sealed == true { return ("", false) } |
| return (Self.chosenName(r?.name, nameSet: r?.name_set), r?.avatar ?? false) |
| } |
| @@ -403,7 +410,10 @@ public struct LChatAPI { |
| guard let url = URL(string: "/api/server-avatar", relativeTo: baseURL), |
| let (data, resp) = try? await session.data(from: url), |
| let http = resp as? HTTPURLResponse, http.statusCode == 200, |
| - !data.isEmpty |
| + !data.isEmpty, |
| + // Sealed-branding ciphertext, not an image — callers would just |
| + // fail the decode; skip it explicitly. |
| + http.value(forHTTPHeaderField: "Content-Type") != "application/vnd.lchat.sealed" |
| else { return nil } |
| return data |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift b/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift| index 4a8da25..0d42353 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift |
| @@ -194,13 +194,38 @@ public enum SpaceClaim { |
| /// The "~<encoded name>" tail some links carry (invite.js: the inviter's |
| /// suggestion / an add-device link's owner) — never sent to any server. |
| + /// Sealed instances append a third "~<branding key>" slot |
| + /// (docs/no-plaintext.md); it is not part of the name. |
| static func suggestedName(fromFragment fragment: String) -> String? { |
| - guard let tilde = fragment.firstIndex(of: "~") else { return nil } |
| - let raw = String(fragment[fragment.index(after: tilde)...]) |
| + var slots = fragmentSlots(fragment) |
| + guard slots.count >= 2 else { return nil } |
| + if slots.count >= 3, isBrandingKey(slots[slots.count - 1]) { slots.removeLast() } |
| + let raw = slots[1...].joined(separator: "~") // legacy links may carry a raw "~" in the name |
| let name = raw.removingPercentEncoding ?? raw |
| return name.isEmpty ? nil : name |
| } |
| + /// The branding key from a sealed instance's link (the third fragment |
| + /// slot) — held for the sealed-branding rendering path; never sent to any |
| + /// server. Nil when the link predates sealing or the tail isn't key-shaped. |
| + static func brandingKey(fromFragment fragment: String) -> String? { |
| + let slots = fragmentSlots(fragment) |
| + guard slots.count >= 3, isBrandingKey(slots[slots.count - 1]) else { return nil } |
| + return slots[slots.count - 1] |
| + } |
| + |
| + private static func fragmentSlots(_ fragment: String) -> [String] { |
| + var f = fragment |
| + if f.hasPrefix("e1.") { f = String(f.dropFirst(3)) } |
| + return f.split(separator: "~", omittingEmptySubsequences: false).map(String.init) |
| + } |
| + |
| + /// invite.js BRANDING_KEY_RE: lowercase base32, 8–32 chars. |
| + private static func isBrandingKey(_ s: String) -> Bool { |
| + guard s.count >= 8, s.count <= 32 else { return false } |
| + return s.allSatisfy { ("a"..."z").contains($0) || ("2"..."7").contains($0) } |
| + } |
| + |
| private static func getJSON<T: Decodable>(_ url: URL) async throws -> T { |
| let (data, _) = try await URLSession.shared.data(from: url) |
| return try JSONDecoder().decode(T.self, from: data) |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/SpaceCreateTests.swift b/ios/LChatCore/Tests/LChatCoreTests/SpaceCreateTests.swift| index 655dcb8..8295dd4 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/SpaceCreateTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/SpaceCreateTests.swift |
| @@ -64,6 +64,26 @@ final class SpaceCreateTests: XCTestCase { |
| XCTAssertNil(SpaceClaim.inviteSecret(fromFragment: "")) |
| XCTAssertNil(SpaceClaim.inviteSecret(fromFragment: "e1.")) |
| } |
| + |
| + // Sealed instances mint 3-slot links: e1.<T>~<name>~<branding key> |
| + // (docs/no-plaintext.md). T parsing and the suggested name must both |
| + // survive the new tail; the key is exposed for the sealed-branding path. |
| + func testBrandingKeyFragmentSlots() { |
| + let frag = "e1.abc123~Paul~k3m7p2qx4f" |
| + XCTAssertEqual(SpaceClaim.inviteSecret(fromFragment: frag), "abc123") |
| + XCTAssertEqual(SpaceClaim.suggestedName(fromFragment: frag), "Paul") |
| + XCTAssertEqual(SpaceClaim.brandingKey(fromFragment: frag), "k3m7p2qx4f") |
| + |
| + // Empty name slot (a sealed instance's admin link). |
| + XCTAssertEqual(SpaceClaim.inviteSecret(fromFragment: "e1.abc123~~k3m7p2qx4f"), "abc123") |
| + XCTAssertNil(SpaceClaim.suggestedName(fromFragment: "e1.abc123~~k3m7p2qx4f")) |
| + XCTAssertEqual(SpaceClaim.brandingKey(fromFragment: "e1.abc123~~k3m7p2qx4f"), "k3m7p2qx4f") |
| + |
| + // Two-slot legacy links: no key, name intact — even when a legacy raw |
| + // "~" in the name makes a non-key-shaped tail. |
| + XCTAssertNil(SpaceClaim.brandingKey(fromFragment: "e1.abc123~Paul")) |
| + XCTAssertEqual(SpaceClaim.suggestedName(fromFragment: "e1.abc123~Zo%C3%AB%20~%20%E2%9C%A8"), "Zoë ~ ✨") |
| + } |
| } |
| private func SHA256Hex(_ data: Data) -> String { |
diff --git a/main.go b/main.go| index 7219c5b..773c9d0 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -503,6 +503,10 @@ func (s *Server) handler() http.Handler { |
| mux.HandleFunc("GET /api/member-app-guide", s.getMemberAppGuide) |
| mux.HandleFunc("GET /api/check-app.mjs", s.getAppChecker) |
| mux.HandleFunc("POST /api/admin/group", s.setGroup) |
| + // Sealed branding (docs/no-plaintext.md): the client-sealed branding set |
| + // and its reversal. Admin-gated; the ciphertext is produced client-side. |
| + mux.HandleFunc("POST /api/admin/branding/seal", s.sealBranding) |
| + mux.HandleFunc("POST /api/admin/branding/unseal", s.unsealBranding) |
| // Admin API (requires admin token) |
| mux.HandleFunc("GET /api/admin/settings", s.getSettings) |
| @@ -637,7 +641,9 @@ func (s *Server) servePage(name string) http.HandlerFunc { |
| if name == "join.html" { |
| base := requestBase(r) |
| ogImage := base + "/static/icon-512.png" |
| - if _, _, _, hasAvatar := s.db.getInstanceAvatar(); hasAvatar { |
| + // A sealed avatar is ciphertext — crawlers can't render it, so OG |
| + // cards keep the app logo (the group name is likewise the default). |
| + if _, mime, _, hasAvatar := s.db.getInstanceAvatar(); hasAvatar && mime != sealedAvatarMime { |
| ogImage = base + "/api/server-avatar" |
| } |
| page = strings.ReplaceAll(page, "__OG_URL__", html.EscapeString(base+r.URL.EscapedPath())) |
| @@ -2085,6 +2091,9 @@ func (s *Server) postGroupDescription(w http.ResponseWriter, r *http.Request) { |
| if !s.requireAdmin(w, r) { |
| return |
| } |
| + if !s.requireUnsealed(w) { |
| + return |
| + } |
| var body struct { |
| Description string `json:"description"` |
| } |
| @@ -2670,6 +2679,15 @@ func (s *Server) getKeyInvite(w http.ResponseWriter, r *http.Request) { |
| if d := s.db.getGroupDescription(); d != "" { |
| resp["description"] = d |
| } |
| + // Sealed branding: the invite link's #fragment carries B, so the join |
| + // card decrypts the full branding set (name, description, avatar alt) |
| + // from this blob instead of the plaintext fields above. |
| + if s.db.brandingSealed() { |
| + blob, _, _ := s.db.getConfig("branding_blob") |
| + salt, _, _ := s.db.getConfig("branding_salt") |
| + resp["branding_blob"] = blob |
| + resp["branding_salt"] = salt |
| + } |
| // A cohort invite says which access level the joiner lands in — the |
| // level NAME is branding-class by design (plaintext space policy), and |
| // like the description it's served to link-holders only. |
| @@ -3233,10 +3251,31 @@ func (s *Server) getGroup(w http.ResponseWriter, r *http.Request) { |
| alt, _, _ := s.db.getConfig("server_avatar_alt") |
| out["avatar_alt"] = alt |
| } |
| + // Sealed branding (docs/no-plaintext.md): the flag is public (join cards |
| + // need it to skip rendering ciphertext), but the blob + salt are NOT — |
| + // members get them below, and invitees get them via their unguessable |
| + // invite id (getKeyInvite). Serving ciphertext to bare visitors would only |
| + // widen the old plaintext gating: anyone could start the offline |
| + // brute-force, and any past link-holder could open the admin |
| + // name/email — a tier the plaintext era kept members-only. `name` above |
| + // already fell back to the compiled-in default (seal clears group_name). |
| + if s.db.brandingSealed() { |
| + out["sealed"] = true |
| + } |
| // The administrator identity is member-only, not public branding: this |
| // endpoint is also the unauthenticated onboarding read, so only add the |
| // admin's name + contact email when a valid member token is presented. |
| if s.tokenOK(r.URL.Query().Get("token")) { |
| + // Members get the sealed branding set plus the branding key wrapped |
| + // under the instance key, so nobody has to re-visit an invite link. |
| + if s.db.brandingSealed() { |
| + blob, _, _ := s.db.getConfig("branding_blob") |
| + salt, _, _ := s.db.getConfig("branding_salt") |
| + out["branding_blob"] = blob |
| + out["branding_salt"] = salt |
| + ks, _, _ := s.db.getConfig("branding_key_sealed") |
| + out["branding_key_sealed"] = ks |
| + } |
| out["admin_name"] = s.db.getAdminName() |
| out["admin_email"] = s.db.getAdminEmail() |
| // The space description is member-gated like the admin identity: this |
| @@ -3282,10 +3321,15 @@ func (s *Server) getServerAvatar(w http.ResponseWriter, r *http.Request) { |
| // setServerAvatar (admin) stores a new branding photo. The body is the raw image |
| // bytes; the type is sniffed from the content, not trusted from a header. |
| +// While branding is sealed, photos go through sealBranding instead (the bytes |
| +// here would be readable at rest). |
| func (s *Server) setServerAvatar(w http.ResponseWriter, r *http.Request) { |
| if !s.requireAdmin(w, r) { |
| return |
| } |
| + if !s.requireUnsealed(w) { |
| + return |
| + } |
| r.Body = http.MaxBytesReader(w, r.Body, maxServerAvatarBytes) |
| data, err := io.ReadAll(r.Body) |
| if err != nil { |
| @@ -3336,6 +3380,9 @@ func (s *Server) setGroup(w http.ResponseWriter, r *http.Request) { |
| if !s.requireAdmin(w, r) { |
| return |
| } |
| + if !s.requireUnsealed(w) { |
| + return |
| + } |
| var body struct { |
| Name string `json:"name"` |
| AdminName string `json:"admin_name"` |
| @@ -3529,6 +3576,12 @@ func (s *Server) setSettings(w http.ResponseWriter, r *http.Request) { |
| } |
| } |
| if body.AvatarAlt != nil { |
| + // The one branding field in this settings grab-bag — while sealed it |
| + // must 409 like every other plaintext branding setter (alt text rides |
| + // inside the sealed blob instead). |
| + if !s.requireUnsealed(w) { |
| + return |
| + } |
| if err := s.db.setConfig("server_avatar_alt", trimAltText(*body.AvatarAlt)); err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
diff --git a/test/crypto.test.mjs b/test/crypto.test.mjs| index e40093f..4f66b78 100644 |
| --- a/test/crypto.test.mjs |
| +++ b/test/crypto.test.mjs |
| @@ -17,6 +17,9 @@ import { |
| generateUserKeypair, |
| wrapThreadKey, |
| unwrapThreadKey, |
| + mintBrandingKey, |
| + deriveBrandingKey, |
| + BRANDING_KEY_CHARS, |
| } from "../web/static/crypto.js"; |
| let failures = 0; |
| @@ -149,5 +152,37 @@ await test("user keypair: a group of members each unwrap the same thread key", a |
| } |
| }); |
| + |
| +await test("branding key: mint shape + derive round-trips + wrong key fails", async () => { |
| + const b = mintBrandingKey(); |
| + assert.match(b, new RegExp(`^[a-z2-7]{${BRANDING_KEY_CHARS}}$`)); |
| + const salt = bytesToB64url(crypto.getRandomValues(new Uint8Array(16))); |
| + const key = await deriveBrandingKey(b, salt); |
| + const blob = await encrypt(key, { name: "Family Chat", description: "Our cosy corner" }); |
| + const opened = await decrypt(await deriveBrandingKey(b, salt), blob); |
| + assert.equal(opened.name, "Family Chat"); |
| + // A different key (or a different salt) must not open it. |
| + let leaked = false; |
| + try { |
| + await decrypt(await deriveBrandingKey(mintBrandingKey(), salt), blob); |
| + leaked = true; |
| + } catch {} |
| + assert.equal(leaked, false); |
| +}); |
| + |
| +// PINNED cross-platform vector — not a snapshot. This blob was produced by |
| +// THIS code (PBKDF2-SHA256, 600k iterations, AES-GCM-256, iv||ct b64url); a |
| +// sealed instance's blobs live on servers indefinitely, so any KDF/param |
| +// change silently bricks them. If this test fails, you changed the derivation |
| +// — don't re-pin; add a versioned migration instead. The Swift CommonCrypto |
| +// implementation must decrypt this same vector when native rendering lands. |
| +await test("branding key: pinned KDF vector decrypts", async () => { |
| + const b = "k3m7p2qx4f"; |
| + const salt = "AAECAwQFBgcICQoLDA0ODw"; // bytes 0x00..0x0f |
| + const blob = "uu9h74mrboFkgWSzYhnd67QKxlD7k9t6nJ79flw9UKkdkTMIZRTtxOcBW-ivDIckbudRf9uB64AXiGB4qcPfOA"; |
| + const opened = await decrypt(await deriveBrandingKey(b, salt), blob); |
| + assert.deepEqual(opened, { name: "Vector Pin", emoji: "🔏" }); |
| +}); |
| + |
| console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| process.exit(failures === 0 ? 0 : 1); |
diff --git a/test/invite.test.mjs b/test/invite.test.mjs| index dda4cff..0ad7324 100644 |
| --- a/test/invite.test.mjs |
| +++ b/test/invite.test.mjs |
| @@ -19,40 +19,40 @@ async function test(name, fn) { |
| } |
| await test("parse: new format without name", () => { |
| - assert.deepEqual(parseInviteFragment("e1.abc123"), { T: "abc123", name: "" }); |
| + assert.deepEqual(parseInviteFragment("e1.abc123"), { T: "abc123", name: "", b: "" }); |
| }); |
| await test("parse: new format with name", () => { |
| - assert.deepEqual(parseInviteFragment("e1.abc123~Paul"), { T: "abc123", name: "Paul" }); |
| + assert.deepEqual(parseInviteFragment("e1.abc123~Paul"), { T: "abc123", name: "Paul", b: "" }); |
| }); |
| await test("parse: legacy unprefixed without name", () => { |
| - assert.deepEqual(parseInviteFragment("abc123"), { T: "abc123", name: "" }); |
| + assert.deepEqual(parseInviteFragment("abc123"), { T: "abc123", name: "", b: "" }); |
| }); |
| await test("parse: legacy unprefixed with name", () => { |
| - assert.deepEqual(parseInviteFragment("abc123~Paul"), { T: "abc123", name: "Paul" }); |
| + assert.deepEqual(parseInviteFragment("abc123~Paul"), { T: "abc123", name: "Paul", b: "" }); |
| }); |
| await test("parse: URL-encoded name", () => { |
| - assert.deepEqual(parseInviteFragment("e1.abc~Paul%20Campbell"), { T: "abc", name: "Paul Campbell" }); |
| + assert.deepEqual(parseInviteFragment("e1.abc~Paul%20Campbell"), { T: "abc", name: "Paul Campbell", b: "" }); |
| }); |
| await test("parse: URL-encoded unicode name", () => { |
| - assert.deepEqual(parseInviteFragment("e1.abc~Zo%C3%AB%20%E2%9C%A8"), { T: "abc", name: "Zoë ✨" }); |
| + assert.deepEqual(parseInviteFragment("e1.abc~Zo%C3%AB%20%E2%9C%A8"), { T: "abc", name: "Zoë ✨", b: "" }); |
| }); |
| await test("parse: undecodable percent sequence -> empty name", () => { |
| - assert.deepEqual(parseInviteFragment("e1.abc~%E0%A4%A"), { T: "abc", name: "" }); |
| + assert.deepEqual(parseInviteFragment("e1.abc~%E0%A4%A"), { T: "abc", name: "", b: "" }); |
| }); |
| await test("parse: empty input", () => { |
| - assert.deepEqual(parseInviteFragment(""), { T: "", name: "" }); |
| + assert.deepEqual(parseInviteFragment(""), { T: "", name: "", b: "" }); |
| }); |
| await test("parse: null/undefined input", () => { |
| - assert.deepEqual(parseInviteFragment(null), { T: "", name: "" }); |
| - assert.deepEqual(parseInviteFragment(undefined), { T: "", name: "" }); |
| + assert.deepEqual(parseInviteFragment(null), { T: "", name: "", b: "" }); |
| + assert.deepEqual(parseInviteFragment(undefined), { T: "", name: "", b: "" }); |
| }); |
| await test("build: carries the fingerprint", () => { |
| @@ -61,20 +61,21 @@ await test("build: carries the fingerprint", () => { |
| }); |
| await test("build: round-trips without name", () => { |
| - assert.deepEqual(parseInviteFragment(buildInviteFragment("abc123")), { T: "abc123", name: "" }); |
| + assert.deepEqual(parseInviteFragment(buildInviteFragment("abc123")), { T: "abc123", name: "", b: "" }); |
| }); |
| await test("build: round-trips with name", () => { |
| assert.deepEqual(parseInviteFragment(buildInviteFragment("abc123", "Paul Campbell")), { |
| T: "abc123", |
| name: "Paul Campbell", |
| + b: "", |
| }); |
| }); |
| await test("build: round-trips a unicode name (incl. ~ in the name)", () => { |
| // "~" in a name must survive: encodeURIComponent leaves it bare, but the |
| // parser splits on the FIRST tilde, so the separator still wins. |
| - assert.deepEqual(parseInviteFragment(buildInviteFragment("T", "Zoë ~ ✨")), { T: "T", name: "Zoë ~ ✨" }); |
| + assert.deepEqual(parseInviteFragment(buildInviteFragment("T", "Zoë ~ ✨")), { T: "T", name: "Zoë ~ ✨", b: "" }); |
| }); |
| await test("hasInviteFingerprint: new links yes, legacy and junk no", () => { |
| @@ -84,5 +85,39 @@ await test("hasInviteFingerprint: new links yes, legacy and junk no", () => { |
| assert.equal(hasInviteFingerprint(null), false); |
| }); |
| + |
| +await test("branding key: builds the 3-slot form and round-trips", () => { |
| + // Sealed instances put the branding key b in every minted link |
| + // (docs/no-plaintext.md); the name slot stays, possibly empty. |
| + assert.equal(buildInviteFragment("abc123", "", "k3m7p2qx4f"), "e1.abc123~~k3m7p2qx4f"); |
| + assert.deepEqual(parseInviteFragment("e1.abc123~~k3m7p2qx4f"), { T: "abc123", name: "", b: "k3m7p2qx4f" }); |
| + assert.deepEqual(parseInviteFragment(buildInviteFragment("abc123", "Paul Campbell", "k3m7p2qx4f")), { |
| + T: "abc123", |
| + name: "Paul Campbell", |
| + b: "k3m7p2qx4f", |
| + }); |
| +}); |
| + |
| +await test("branding key: old parsers still get T from a 3-slot link", () => { |
| + // Slot order is fixed (T first) so a pre-branding parser that cuts at the |
| + // first "~" still claims from a new link. |
| + const frag = buildInviteFragment("abc123", "Zoë", "k3m7p2qx4f").replace(/^e1\./, ""); |
| + assert.equal(frag.split("~")[0], "abc123"); |
| +}); |
| + |
| +await test("branding key: a legacy raw-~ name is not mistaken for a key", () => { |
| + // Old builders left "~" unescaped in names. The key slot must match the |
| + // base32 shape, so a decorative tail stays part of the name. |
| + assert.deepEqual(parseInviteFragment("e1.T~Zo%C3%AB%20~%20%E2%9C%A8"), { T: "T", name: "Zoë ~ ✨", b: "" }); |
| +}); |
| + |
| +await test("branding key: new builders escape ~ so names with ~ round-trip beside b", () => { |
| + assert.deepEqual(parseInviteFragment(buildInviteFragment("T", "Zoë ~ ✨", "k3m7p2qx4f")), { |
| + T: "T", |
| + name: "Zoë ~ ✨", |
| + b: "k3m7p2qx4f", |
| + }); |
| +}); |
| + |
| console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| process.exit(failures === 0 ? 0 : 1); |
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js| index c238b54..1765893 100644 |
| --- a/web/static/apps/admin.js |
| +++ b/web/static/apps/admin.js |
| @@ -284,7 +284,15 @@ export const app = defineApp({ |
| previewGlyph.style.display = hasPhoto ? "none" : ""; |
| previewPhoto.style.display = hasPhoto ? "" : "none"; |
| if (hasPhoto) { |
| - previewPhoto.src = "/api/server-avatar?t=" + Date.now(); |
| + // Via the bridge: while sealed the raw endpoint serves ciphertext, |
| + // and the shell hands back a decrypted object URL instead. |
| + bridge.serverAvatarURL().then((url) => { |
| + if (url) previewPhoto.src = url; |
| + else { |
| + previewGlyph.style.display = ""; |
| + previewPhoto.style.display = "none"; |
| + } |
| + }); |
| previewPhoto.alt = alt || ""; |
| } |
| }; |
| @@ -369,6 +377,44 @@ export const app = defineApp({ |
| } |
| }; |
| + // ---- Privacy: sealed branding (docs/no-plaintext.md) ------------------------ |
| + // One switch: lock the space's name, description, photo, and admin contact |
| + // into a sealed blob only link-holders can open. The shell does all crypto; |
| + // this is pure UI over bridge.brandingSeal/brandingUnseal. |
| + const sealSec = section("Privacy"); |
| + const sealDesc = document.createElement("p"); |
| + sealDesc.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 8px"; |
| + sealDesc.textContent = |
| + "Normally the space's name, photo, and description sit on the server so invite pages can show them. " + |
| + "Sealing locks them in a box only your invite links can open — the people who run the server are never sent a key. " + |
| + "Messages and photos people share are always locked, either way. " + |
| + "For now the iPhone and Mac apps can't open the box yet, so they show a generic name and no photo while sealed."; |
| + sealSec.appendChild(sealDesc); |
| + const sealCheck = switchRow(sealSec, "Seal the space's name and photo"); |
| + const sealStatus = statusLine(sealSec); |
| + sealCheck.disabled = true; |
| + bridge.brandingSealed().then((sealed) => { |
| + sealCheck.checked = sealed; |
| + sealCheck.disabled = false; |
| + }); |
| + sealCheck.onchange = async () => { |
| + const want = sealCheck.checked; |
| + sealCheck.disabled = true; |
| + const r = want ? await bridge.brandingSeal() : await bridge.brandingUnseal(); |
| + sealCheck.disabled = false; |
| + if (r === "ok") { |
| + flash( |
| + sealStatus, |
| + want |
| + ? "Sealed. New invite links carry the key; the server holds only the locked box." |
| + : "Unsealed. The name and description are readable on the server again — re-add the photo if you had one.", |
| + ); |
| + } else { |
| + sealCheck.checked = !want; // revert — the server didn't take it |
| + flash(sealStatus, "Couldn't change the seal — try again."); |
| + } |
| + }; |
| + |
| // ---- Features -------------------------------------------------------------- |
| // The per-instance gates (default on). Each switch is one partial write; |
| // the server enforces the gate — these entry points just follow suit on |
| @@ -975,7 +1021,11 @@ export const app = defineApp({ |
| photoImg.style.display = has ? "" : "none"; |
| photoRemove.style.display = has ? "" : "none"; |
| altRow.style.display = has ? "" : "none"; // alt text only means something with a photo |
| - if (has) photoImg.src = "/api/server-avatar?t=" + Date.now(); |
| + if (has) |
| + bridge.serverAvatarURL().then((url) => { |
| + if (url) photoImg.src = url; |
| + else photoImg.style.display = "none"; // sealed, no key to open it |
| + }); |
| photoPick.textContent = has ? "Change" : "Upload"; |
| paintPreview(nameInput.value.trim(), has, photoState.alt); |
| }; |
diff --git a/web/static/crypto.js b/web/static/crypto.js| index 289e2d5..9e4043c 100644 |
| --- a/web/static/crypto.js |
| +++ b/web/static/crypto.js |
| @@ -225,3 +225,43 @@ export async function pkcePair() { |
| const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))); |
| return { verifier, challenge: bytesToB64url(digest) }; |
| } |
| + |
| +// ---- the branding key (docs/no-plaintext.md) ------------------------------ |
| +// |
| +// A per-instance key B, one trust rung below the instance key: it rides share |
| +// links' #fragments (never sent to the server) and unlocks the sealed branding |
| +// blob — the space's name, description, photo, and admin identity. B is short |
| +// by design (bulk-exposure protection, not targeted secrecy — the doc has the |
| +// honest math), so derivation is deliberately stretched with PBKDF2, the one |
| +// stretching KDF native to both WebCrypto and CommonCrypto (no-build-step rule |
| +// rules out WASM Argon2). Clients cache the derived key per session. |
| + |
| +export const BRANDING_KEY_CHARS = 10; // lowercase base32 → 50 bits |
| +export const BRANDING_KDF_ITERATIONS = 600_000; |
| + |
| +// mintBrandingKey() -> e.g. "k3m7p2qx4f". Uniform over the base32 alphabet |
| +// via rejection sampling (no modulo bias). |
| +export function mintBrandingKey() { |
| + const alphabet = "abcdefghijklmnopqrstuvwxyz234567"; |
| + let out = ""; |
| + while (out.length < BRANDING_KEY_CHARS) { |
| + const [byte] = crypto.getRandomValues(new Uint8Array(1)); |
| + if (byte < 224) out += alphabet[byte % 32]; // 224 = 7×32: reject the biased tail |
| + } |
| + return out; |
| +} |
| + |
| +// deriveBrandingKey(b, saltB64) -> AES-GCM CryptoKey usable with the |
| +// encrypt/decrypt/encryptBytes/decryptBytes helpers above. |
| +export async function deriveBrandingKey(b, saltB64) { |
| + const ikm = await crypto.subtle.importKey("raw", new TextEncoder().encode(b), "PBKDF2", false, [ |
| + "deriveKey", |
| + ]); |
| + return crypto.subtle.deriveKey( |
| + { name: "PBKDF2", hash: "SHA-256", salt: b64urlToBytes(saltB64), iterations: BRANDING_KDF_ITERATIONS }, |
| + ikm, |
| + { name: "AES-GCM", length: 256 }, |
| + false, |
| + ["encrypt", "decrypt"], |
| + ); |
| +} |
diff --git a/web/static/invite.js b/web/static/invite.js| index f2fa44f..184888d 100644 |
| --- a/web/static/invite.js |
| +++ b/web/static/invite.js |
| @@ -7,21 +7,41 @@ |
| export const INVITE_PREFIX = "e1."; |
| -// parseInviteFragment("e1.<T>~<name>") -> { T, name }, name URL-decoded ("" if |
| -// absent or undecodable). Accepts unprefixed legacy fragments. |
| +// The branding key's shape (docs/no-plaintext.md): lowercase base32, minted |
| +// 10 chars, tolerated 8–32 so the length can be dialed without a format bump. |
| +// Used to disambiguate the third fragment slot from legacy names containing a |
| +// raw "~" (encodeURIComponent leaves "~" unescaped; new builders escape it). |
| +const BRANDING_KEY_RE = /^[a-z2-7]{8,32}$/; |
| + |
| +// parseInviteFragment("e1.<T>~<name>~<b>") -> { T, name, b }, name URL-decoded |
| +// ("" if absent or undecodable), b the branding key when the link carries one |
| +// (sealed instances put it in every minted link). Accepts unprefixed legacy |
| +// fragments and the older 1- and 2-slot forms; slot order is fixed (T first) |
| +// so old parsers keep claiming from new links. |
| export function parseInviteFragment(frag) { |
| let f = frag || ""; |
| if (f.startsWith(INVITE_PREFIX)) f = f.slice(INVITE_PREFIX.length); |
| - const tilde = f.indexOf("~"); |
| - const T = tilde < 0 ? f : f.slice(0, tilde); |
| - const name = tilde < 0 ? "" : safeDecode(f.slice(tilde + 1)); |
| - return { T, name }; |
| + const parts = f.split("~"); |
| + const T = parts[0]; |
| + let name = parts.slice(1).join("~"); |
| + let b = ""; |
| + const last = parts[parts.length - 1]; |
| + if (parts.length >= 3 && BRANDING_KEY_RE.test(last)) { |
| + b = last; |
| + name = parts.slice(1, -1).join("~"); |
| + } |
| + return { T, name: safeDecode(name), b }; |
| } |
| -// buildInviteFragment(T, name?) -> "e1.<T>" or "e1.<T>~<encoded name>". All |
| -// minters use this, so every new link carries the fingerprint. |
| -export function buildInviteFragment(T, name = "") { |
| - return INVITE_PREFIX + T + (name ? "~" + encodeURIComponent(name) : ""); |
| +// buildInviteFragment(T, name?, b?) -> "e1.<T>", "e1.<T>~<encoded name>", or |
| +// "e1.<T>~<encoded name>~<b>" (name slot kept, possibly empty, whenever b is |
| +// present). All minters use this, so every new link carries the fingerprint — |
| +// and, on a sealed instance, the branding key. |
| +export function buildInviteFragment(T, name = "", b = "") { |
| + let f = INVITE_PREFIX + T; |
| + if (name || b) f += "~" + encodeURIComponent(name).replace(/~/g, "%7E"); |
| + if (b) f += "~" + b; |
| + return f; |
| } |
| // True when the fragment carries the versioned fingerprint — the deterministic |
diff --git a/web/static/join.js b/web/static/join.js| index d5aa5f9..dc27e47 100644 |
| --- a/web/static/join.js |
| +++ b/web/static/join.js |
| @@ -3,7 +3,15 @@ |
| // the server. Joining sets up the user's keypair — generated fresh for a new |
| // person, or unwrapped from the link for an added device — registers the public |
| // key in the roster, claims a device token, and stores the session. |
| -import { decrypt, generateUserKeypair, deriveInvite, wrapThreadKey } from "/static/crypto.js"; |
| +import { |
| + decrypt, |
| + decryptBytes, |
| + deriveBrandingKey, |
| + b64urlToBytes, |
| + generateUserKeypair, |
| + deriveInvite, |
| + wrapThreadKey, |
| +} from "/static/crypto.js"; |
| import { parseInviteFragment } from "/static/invite.js"; |
| const SESSION_KEY = "lchat_session"; |
| @@ -24,7 +32,7 @@ function showCard() { |
| const isStandalone = |
| window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true; |
| -const { T, name: fragName } = parseInviteFragment(location.hash.slice(1)); |
| +const { T, name: fragName, b: fragB } = parseInviteFragment(location.hash.slice(1)); |
| // ?src=home marks a link opened from the person's Eleven sign-in page (the |
| // create-a-space flow). Remember it on this origin so the setup conversation's |
| @@ -59,11 +67,15 @@ function init() { |
| // claiming/adopting). Set once in start(); read by finish()/adoptExisting(). |
| let inviteClaimSecret = ""; |
| -// Whether this instance has a branding photo (public, unencrypted — like the |
| -// group name). Fetched in start() and shown on the onboarding cards in place of |
| -// the generic emoji. See docs/onboarding-and-home-server.md. |
| +// Whether this instance has a branding photo. Fetched in start() and shown on |
| +// the onboarding cards in place of the generic emoji. On a sealed instance |
| +// (docs/no-plaintext.md) the photo is ciphertext: serverPhotoURL becomes a |
| +// decrypted blob: URL when the link carries the branding key, else the photo |
| +// (and the readable name/description) simply doesn't show — the link still |
| +// claims fine. See docs/onboarding-and-home-server.md. |
| let serverHasAvatar = false; |
| let serverAvatarAlt = ""; |
| +let serverPhotoURL = "/api/server-avatar"; |
| async function start() { |
| let id, wrapKey, info; |
| @@ -91,19 +103,57 @@ async function start() { |
| // The space's description rides on the invite lookup (holders of a valid link |
| // only — it's never on the public read), so the welcome card can say what |
| // this place is before the joiner holds any key. |
| - const spaceDesc = typeof info.description === "string" ? info.description : ""; |
| + let spaceDesc = typeof info.description === "string" ? info.description : ""; |
| // A cohort invite names the access level the joiner lands in (plaintext by |
| // design, like the description — served to link-holders only). |
| cohortName = typeof info.tier_name === "string" ? info.tier_name : ""; |
| // Note whether this instance has a branding photo (a public flag), so the |
| // onboarding cards below can show it instead of the placeholder emoji. |
| + let groupInfo = {}; |
| try { |
| - const g = await (await fetch("/api/group")).json(); |
| - serverHasAvatar = !!g.avatar; |
| - serverAvatarAlt = g.avatar_alt || ""; |
| + groupInfo = await (await fetch("/api/group")).json(); |
| + serverHasAvatar = !!groupInfo.avatar; |
| + serverAvatarAlt = groupInfo.avatar_alt || ""; |
| } catch {} |
| + // Sealed branding: this link's fragment carries the branding key, so open |
| + // the blob and render the real name/description/photo in place of the |
| + // defaults. A link without the key (minted before sealing) still claims — |
| + // the card just stays generic. Decrypt failures are treated the same way. |
| + if (info.branding_blob && info.branding_salt && fragB) { |
| + try { |
| + const bKey = await deriveBrandingKey(fragB, info.branding_salt); |
| + const branding = await decrypt(bKey, info.branding_blob); |
| + if (branding.name) { |
| + document.title = branding.name; |
| + // The h1 was server-baked with the generic fallback (the server can't |
| + // read the sealed name); the standard join card never re-sets it, so |
| + // fill it here. The device/adopt/admin kinds overwrite it later. |
| + const h1 = document.querySelector("#welcome-title"); |
| + if (h1) h1.textContent = `You're invited to ${branding.name}`; |
| + } |
| + if (branding.description) spaceDesc = branding.description; |
| + serverAvatarAlt = branding.avatar_alt || serverAvatarAlt; |
| + // Post-claim, the app bootstraps the branding key from here (it also |
| + // has the instance-key-sealed copy as a fallback). Stash it before the |
| + // photo work below: a failed photo decrypt must not cost the handoff. |
| + try { localStorage.setItem("lchat_branding_b", fragB); } catch {} |
| + if (serverHasAvatar && groupInfo.sealed) { |
| + try { |
| + const sealedBytes = await (await fetch("/api/server-avatar")).text(); |
| + const photo = await decryptBytes(bKey, b64urlToBytes(sealedBytes)); |
| + serverPhotoURL = URL.createObjectURL(new Blob([photo], { type: branding.avatar_mime || "image/jpeg" })); |
| + } catch { |
| + serverHasAvatar = false; // ciphertext isn't an image — show the placeholder |
| + } |
| + } |
| + } catch { /* stay generic */ } |
| + } else if (groupInfo.sealed && serverHasAvatar) { |
| + // Sealed photo but no key in this link: the ciphertext isn't an image. |
| + serverHasAvatar = false; |
| + } |
| + |
| // Everything below only *decides* what continuing means for this link — the |
| // claim itself waits for an explicit choice on the welcome card. Claiming on |
| // page-load burned the one-time secret in whatever context rendered the link |
| @@ -477,7 +527,7 @@ function applyServerPhoto(root) { |
| if (!serverHasAvatar) return; |
| const img = root.querySelector(".onboard-photo"); |
| if (!img) return; |
| - img.src = "/api/server-avatar"; |
| + img.src = serverPhotoURL; // same-origin endpoint, or a decrypted blob: URL when sealed |
| img.alt = serverAvatarAlt; // the admin's alt text travels with the photo |
| img.classList.remove("hidden"); |
| const emoji = root.querySelector(".onboard-icon"); |
diff --git a/web/static/shell.js b/web/static/shell.js| index 145c0fc..f4eac1e 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -20,8 +20,11 @@ import { |
| openSealed, |
| pkcePair, |
| bytesToB64url, |
| + b64urlToBytes, |
| randomSecret, |
| deriveInvite, |
| + mintBrandingKey, |
| + deriveBrandingKey, |
| } from "/static/crypto.js"; |
| import { |
| shouldSend, |
| @@ -445,6 +448,231 @@ let attachSeq = 0; // local id counter for staged attachments (drives per-chip r |
| const blobURLs = {}; // blob id -> decrypted object URL (cache) |
| let instanceKey = null; // shared key for all-members content (profile photos) |
| let instanceKeyB64 = null; |
| + |
| +// ---- sealed branding (docs/no-plaintext.md) ------------------------------- |
| +// On a sealed instance the space's name/description/photo/admin identity live |
| +// in one server-held ciphertext blob, opened with the link-carried branding |
| +// key B. Members hold B here (localStorage after a link or an instance-key |
| +// unwrap); every minted invite link carries it in the #fragment. |
| +let brandingB = null; // the short branding key (lowercase base32) |
| +let brandingSalt = ""; // PBKDF2 salt for deriving from B (not secret) |
| +let sealedBranding = null; // decrypted {name, description, admin_name, admin_email, avatar_alt, avatar_mime} |
| +let serverBrandingSealed = false; |
| + |
| +// bootstrapBranding: after keys are up, learn whether this instance is sealed |
| +// and open the blob. B comes from localStorage (a link stashed it), else from |
| +// the copy sealed under the instance key — so members never re-visit a link. |
| +async function bootstrapBranding() { |
| + let g; |
| + try { |
| + g = await (await fetch(`/api/group?token=${encodeURIComponent(session.token)}`)).json(); |
| + } catch { |
| + return; |
| + } |
| + if (!g || !g.sealed || !g.branding_blob) return; |
| + serverBrandingSealed = true; |
| + brandingSalt = g.branding_salt || ""; |
| + try { |
| + brandingB = localStorage.getItem("lchat_branding_b") || null; |
| + } catch {} |
| + if (!brandingB && g.branding_key_sealed && instanceKey) { |
| + try { |
| + const opened = await decrypt(instanceKey, g.branding_key_sealed); |
| + if (opened && opened.b) { |
| + brandingB = opened.b; |
| + try { |
| + localStorage.setItem("lchat_branding_b", brandingB); |
| + } catch {} |
| + } |
| + } catch {} |
| + } |
| + if (!brandingB) return; |
| + try { |
| + const bKey = await deriveBrandingKey(brandingB, brandingSalt); |
| + sealedBranding = await decrypt(bKey, g.branding_blob); |
| + } catch { |
| + // Stale cached B — an unseal→re-seal mints a fresh key, and a device |
| + // still holding the old one would otherwise render generic forever (and |
| + // an admin device couldn't edit or unseal). The instance-key copy always |
| + // holds the CURRENT B: fall back to it and repair the cache. |
| + sealedBranding = null; |
| + if (g.branding_key_sealed && instanceKey) { |
| + try { |
| + const opened = await decrypt(instanceKey, g.branding_key_sealed); |
| + if (opened && opened.b && opened.b !== brandingB) { |
| + brandingB = opened.b; |
| + try { |
| + localStorage.setItem("lchat_branding_b", brandingB); |
| + } catch {} |
| + const bKey = await deriveBrandingKey(brandingB, brandingSalt); |
| + sealedBranding = await decrypt(bKey, g.branding_blob); |
| + } |
| + } catch { |
| + sealedBranding = null; // still undecryptable — surfaces stay generic |
| + } |
| + } |
| + } |
| + if (sealedBranding && sealedBranding.name) document.title = sealedBranding.name; |
| +} |
| + |
| +// resealBranding: the sealed counterpart of saveGroupSettings — merge the |
| +// patch into the decrypted set, re-encrypt, and store. The plaintext setters |
| +// 409 while sealed, so every edit routes through here. |
| +async function resealBranding(patch) { |
| + if (!serverBrandingSealed || !sealedBranding || !brandingB || !instanceKey) return "error"; |
| + // Mirror the plaintext setter's 1–60 rule — the server can't see inside the |
| + // blob to enforce it, so the sealed path validates here. |
| + if (patch.name !== undefined && (!patch.name.trim() || patch.name.length > 60)) return "invalid"; |
| + const next = { ...sealedBranding, ...patch }; |
| + try { |
| + const bKey = await deriveBrandingKey(brandingB, brandingSalt); |
| + const body = { |
| + branding_blob: await encrypt(bKey, next), |
| + branding_salt: brandingSalt, |
| + branding_key_sealed: await encrypt(instanceKey, { b: brandingB }), |
| + }; |
| + const r = await fetch("/api/admin/branding/seal", { |
| + method: "POST", |
| + headers: { Authorization: "Bearer " + session.token, "Content-Type": "application/json" }, |
| + body: JSON.stringify(body), |
| + }); |
| + if (r.ok) { |
| + sealedBranding = next; |
| + if (next.name) document.title = next.name; |
| + } |
| + return r.ok ? "ok" : r.status === 400 ? "invalid" : "error"; |
| + } catch { |
| + return "error"; |
| + } |
| +} |
| + |
| +// sealServerPhoto: change the space photo while sealed — the plaintext upload |
| +// endpoint 409s (deliberately), so the new bytes are encrypted under the |
| +// branding key and ride a re-seal (which also carries the new mime + alt |
| +// inside the blob). The sealed counterpart of pickServerPhoto's POST. |
| +async function sealServerPhoto(bytes, mime, alt) { |
| + if (!serverBrandingSealed || !sealedBranding || !brandingB || !instanceKey) return false; |
| + try { |
| + const bKey = await deriveBrandingKey(brandingB, brandingSalt); |
| + const next = { ...sealedBranding, avatar_mime: mime || "image/jpeg" }; |
| + if (alt !== undefined) next.avatar_alt = alt || ""; |
| + const r = await fetch("/api/admin/branding/seal", { |
| + method: "POST", |
| + headers: { Authorization: "Bearer " + session.token, "Content-Type": "application/json" }, |
| + body: JSON.stringify({ |
| + branding_blob: await encrypt(bKey, next), |
| + branding_salt: brandingSalt, |
| + branding_key_sealed: await encrypt(instanceKey, { b: brandingB }), |
| + avatar_sealed: bytesToB64url(await encryptBytes(bKey, bytes)), |
| + }), |
| + }); |
| + if (r.ok) sealedBranding = next; |
| + return r.ok; |
| + } catch { |
| + return false; |
| + } |
| +} |
| + |
| +// sealedServerAvatarURL: a displayable URL for the space photo when sealed — |
| +// /api/server-avatar serves ciphertext, so fetch + decrypt to an object URL. |
| +// Returns null when there's no photo or no key to open it. |
| +async function sealedServerAvatarURL() { |
| + if (!serverBrandingSealed || !sealedBranding || !brandingB) return null; |
| + try { |
| + const resp = await fetch("/api/server-avatar"); |
| + if (!resp.ok) return null; |
| + const bKey = await deriveBrandingKey(brandingB, brandingSalt); |
| + const photo = await decryptBytes(bKey, b64urlToBytes(await resp.text())); |
| + return URL.createObjectURL(new Blob([photo], { type: sealedBranding.avatar_mime || "image/jpeg" })); |
| + } catch { |
| + return null; |
| + } |
| +} |
| + |
| +// sealBrandingNow: flip this instance to sealed branding. Mints B, gathers the |
| +// current plaintext set (including the photo bytes, re-encrypted under B), |
| +// stores the blob + the instance-key-sealed copy of B, and the server clears |
| +// every plaintext counterpart in the same request. Admin-gated server-side. |
| +async function sealBrandingNow() { |
| + if (!instanceKey) return "error"; |
| + try { |
| + const g = await (await fetch(`/api/group?token=${encodeURIComponent(session.token)}`)).json(); |
| + if (g.sealed) return "ok"; |
| + const b = mintBrandingKey(); |
| + const salt = bytesToB64url(crypto.getRandomValues(new Uint8Array(16))); |
| + const bKey = await deriveBrandingKey(b, salt); |
| + const branding = { |
| + name: g.name || "", |
| + description: g.description || "", |
| + admin_name: g.admin_name || "", |
| + admin_email: g.admin_email || "", |
| + avatar_alt: g.avatar_alt || "", |
| + avatar_mime: "", |
| + }; |
| + const body = { |
| + branding_salt: salt, |
| + branding_key_sealed: await encrypt(instanceKey, { b }), |
| + }; |
| + if (g.avatar) { |
| + // The photo must ride the seal: the server clears a plaintext photo it |
| + // gets no sealed replacement for (it must never serve readable bytes |
| + // behind the sealed flag). So a failed fetch aborts — nothing has been |
| + // written yet, and the admin just retries. |
| + const resp = await fetch("/api/server-avatar"); |
| + if (!resp.ok) return "error"; |
| + branding.avatar_mime = resp.headers.get("Content-Type") || "image/jpeg"; |
| + const bytes = new Uint8Array(await resp.arrayBuffer()); |
| + body.avatar_sealed = bytesToB64url(await encryptBytes(bKey, bytes)); |
| + } |
| + body.branding_blob = await encrypt(bKey, branding); |
| + const r = await fetch("/api/admin/branding/seal", { |
| + method: "POST", |
| + headers: { Authorization: "Bearer " + session.token, "Content-Type": "application/json" }, |
| + body: JSON.stringify(body), |
| + }); |
| + if (!r.ok) return r.status === 409 ? "ok" : "error"; |
| + brandingB = b; |
| + brandingSalt = salt; |
| + sealedBranding = branding; |
| + serverBrandingSealed = true; |
| + try { |
| + localStorage.setItem("lchat_branding_b", b); |
| + } catch {} |
| + return "ok"; |
| + } catch { |
| + return "error"; |
| + } |
| +} |
| + |
| +// unsealBrandingNow: back to plaintext branding — the server can't decrypt, so |
| +// we hand it the values we just decrypted. The sealed photo is cleared |
| +// server-side (its bytes are useless in plaintext mode); the admin re-uploads. |
| +async function unsealBrandingNow() { |
| + if (!serverBrandingSealed || !sealedBranding) return "error"; |
| + try { |
| + const r = await fetch("/api/admin/branding/unseal", { |
| + method: "POST", |
| + headers: { Authorization: "Bearer " + session.token, "Content-Type": "application/json" }, |
| + body: JSON.stringify({ |
| + name: sealedBranding.name || "", |
| + description: sealedBranding.description || "", |
| + admin_name: sealedBranding.admin_name || "", |
| + admin_email: sealedBranding.admin_email || "", |
| + avatar_alt: sealedBranding.avatar_alt || "", |
| + }), |
| + }); |
| + if (!r.ok) return "error"; |
| + serverBrandingSealed = false; |
| + sealedBranding = null; |
| + brandingB = null; |
| + try { |
| + localStorage.removeItem("lchat_branding_b"); |
| + } catch {} |
| + return "ok"; |
| + } catch { |
| + return "error"; |
| + } |
| +} |
| // Tiers — "access levels" in every UI surface: admin-curated member sets, |
| // each with its own key on the instance-key pattern (docs/community-and-tiers.md). |
| let tiers = []; // /api/tiers rows, personalised to this user |
| @@ -765,6 +993,7 @@ async function boot() { |
| if (document.getElementById("thread-list")) refreshNamesUI(); |
| }); |
| await Promise.all([setupInstanceKey(), loadThreads(), Promise.race([nickLoad, new Promise((res) => setTimeout(res, 3000))])]); |
| + await bootstrapBranding(); // sealed instances: open the branding blob (needs the instance key for first-time B) |
| await loadTiers(); // before the re-fold: gated groups unwrap via tier keys |
| // The loads race: when the thread list landed first, any open group (key |
| // under the instance key or a tier key) was skipped — fold it in now. |
| @@ -4141,6 +4370,14 @@ setThreadIsGroup((id) => { |
| setSpaceInfo(async () => { |
| try { |
| const g = await (await fetch(`/api/group?token=${encodeURIComponent(session.token)}`)).json(); |
| + if (g.sealed && sealedBranding) { |
| + return { |
| + name: sealedBranding.name || "", |
| + nameSet: true, |
| + adminName: sealedBranding.admin_name || "", |
| + adminEmail: sealedBranding.admin_email || "", |
| + }; |
| + } |
| return { name: g.name || "", nameSet: !!g.name_set, adminName: g.admin_name || "", adminEmail: g.admin_email || "" }; |
| } catch { |
| return {}; |
| @@ -4179,6 +4416,9 @@ const adminAuth = () => ({ Authorization: "Bearer " + session.token, "Content-Ty |
| // Returns "ok" | "invalid" (the server rejected the value) | "error" (auth / |
| // network / anything else) — so the conversation can be honest about which. |
| async function saveGroupSettings(patch) { |
| + // Sealed instances never write plaintext branding (the endpoint 409s) — |
| + // the same edit re-seals the blob instead. |
| + if (serverBrandingSealed) return resealBranding(patch); |
| try { |
| const g = await (await fetch(`/api/group?token=${encodeURIComponent(session.token)}`)).json(); |
| const body = { |
| @@ -4210,6 +4450,13 @@ setSetupBridge({ |
| // the description rides along to its own endpoint (also fed by the /admin |
| // console and the About-this-space sheet). Omitting it leaves it unchanged. |
| saveSpace: async ({ name, email, description }) => { |
| + // Sealed: one re-seal carries the whole set — there is no separate |
| + // plaintext description endpoint to hit (it 409s while sealed). |
| + if (serverBrandingSealed) { |
| + const patch = { name, admin_email: email }; |
| + if (description !== undefined) patch.description = description; |
| + return resealBranding(patch); |
| + } |
| const r = await saveGroupSettings({ name, admin_email: email }); |
| if (description === undefined) return r; |
| try { |
| @@ -4235,6 +4482,8 @@ setSetupBridge({ |
| const plain = await loadBlobBytes(att.id, threadId); |
| if (!plain) return false; |
| const { bytes } = await processImage(new Blob([plain], { type: att.type || "image/jpeg" }), 400); |
| + // Sealed: the plaintext endpoint 409s — the photo rides a re-seal instead. |
| + if (serverBrandingSealed) return sealServerPhoto(bytes, "image/jpeg", alt); |
| const r = await fetch("/api/admin/server-avatar?alt=" + encodeURIComponent(alt || ""), { |
| method: "POST", |
| headers: { Authorization: "Bearer " + session.token }, |
| @@ -4259,6 +4508,8 @@ setSetupBridge({ |
| if (!f) return resolve(false); |
| try { |
| const { bytes } = await processImage(f, 400); |
| + // Sealed: the plaintext endpoint 409s — the photo rides a re-seal instead. |
| + if (serverBrandingSealed) return resolve(await sealServerPhoto(bytes, "image/jpeg", alt)); |
| const r = await fetch("/api/admin/server-avatar?alt=" + encodeURIComponent(alt || ""), { |
| method: "POST", |
| headers: { Authorization: "Bearer " + session.token }, |
| @@ -4331,6 +4582,23 @@ setSetupBridge({ |
| // re-opening it on every load. |
| markWelcomeDone: () => localStorage.removeItem("lchat_welcome_pending"), |
| // ---- the Admin app's reads/writes (role-gated server-side either way) ---- |
| + // Sealed branding (docs/no-plaintext.md): the Admin app's toggle. State is |
| + // read live so the panel reflects a seal done in another tab. |
| + brandingSealed: async () => { |
| + try { |
| + const g = await (await fetch(`/api/group?token=${encodeURIComponent(session.token)}`)).json(); |
| + return !!g.sealed; |
| + } catch { |
| + return serverBrandingSealed; |
| + } |
| + }, |
| + brandingSeal: () => sealBrandingNow(), |
| + brandingUnseal: () => unsealBrandingNow(), |
| + // A displayable space-photo URL: the plaintext endpoint normally, a |
| + // decrypted object URL while sealed (raw /api/server-avatar would be |
| + // ciphertext — a broken <img>). Null when sealed with no openable photo. |
| + serverAvatarURL: async () => |
| + serverBrandingSealed ? sealedServerAvatarURL() : "/api/server-avatar?t=" + Date.now(), |
| isAdmin: async () => { |
| try { |
| const me = await (await fetch(`/api/me?token=${encodeURIComponent(session.token)}`)).json(); |
| @@ -4360,6 +4628,14 @@ setSetupBridge({ |
| fetch(`/api/group?token=${encodeURIComponent(session.token)}`).then((r) => r.json()), |
| fetch("/api/admin/settings", { headers: adminAuth() }).then((r) => r.json()), |
| ]); |
| + // Sealed: the readable set lives in the decrypted blob, not on the wire. |
| + if (g.sealed && sealedBranding) { |
| + g.name = sealedBranding.name || ""; |
| + g.name_set = true; |
| + g.admin_email = sealedBranding.admin_email || ""; |
| + g.description = sealedBranding.description || ""; |
| + g.avatar_alt = sealedBranding.avatar_alt || ""; |
| + } |
| return { |
| name: g.name || "", |
| nameSet: !!g.name_set, |
| @@ -4581,7 +4857,7 @@ setSetupBridge({ |
| if (!res.ok) return null; |
| const data = await res.json(); |
| if (!data.ok) return null; |
| - const frag = buildInviteFragment(T, ""); |
| + const frag = buildInviteFragment(T, "", brandingB || ""); |
| return { link: `${location.origin}/join/join#${frag}` }; |
| } catch { |
| return null; |
| @@ -7514,9 +7790,16 @@ function renameChoices(notice, choose) { |
| } |
| // The group info incl. the space description — plaintext branding-adjacent |
| -// metadata like the admin name/email, member-gated on /api/group. |
| +// metadata like the admin name/email, member-gated on /api/group. On a sealed |
| +// instance the readable values come from the decrypted branding blob. |
| async function fetchSpaceInfo() { |
| const g = await (await fetch(`/api/group?token=${encodeURIComponent(session.token)}`)).json(); |
| + if (g.sealed && sealedBranding) { |
| + g.name = sealedBranding.name || ""; |
| + g.admin_name = sealedBranding.admin_name || ""; |
| + g.admin_email = sealedBranding.admin_email || ""; |
| + g.description = sealedBranding.description || ""; |
| + } |
| return { g, about: g.description || "" }; |
| } |
| @@ -9018,7 +9301,9 @@ function updatePendingBanner(t) { |
| function inviteLink(name, T) { |
| // Neutral slug: the invitee's name rides in the #fragment (never sent to the |
| // server), not the path — so opening a join link reveals no name to the server. |
| - return `${location.origin}/join/join#${buildInviteFragment(T, name)}`; |
| + // On a sealed instance the fragment also carries the branding key, so the |
| + // join card can show what the invitee is joining (docs/no-plaintext.md). |
| + return `${location.origin}/join/join#${buildInviteFragment(T, name, brandingB || "")}`; |
| } |
| function selectAllText(el) { |
| @@ -9056,7 +9341,7 @@ async function addDevice() { |
| }); |
| if (!res.ok) throw new Error("server"); |
| // Neutral slug; the name rides in the #fragment (never sent to the server). |
| - link = `${location.origin}/join/join#${buildInviteFragment(T, session.name)}`; |
| + link = `${location.origin}/join/join#${buildInviteFragment(T, session.name, brandingB || "")}`; |
| } catch { |
| return alert("Couldn't create a device link. Try again."); |
| } |