eleven / messenger Public

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>
Paul Campbell 0b89d79742fb916a630c428672eaaa208b19d064 parent 7328e49
17 files changed, +1102 −58
  • branding.go +175 −0
  • branding_test.go +222 −0
  • changelog/2026-07-11-sealed-branding.md +9 −0
  • db.go +5 −1
  • docs/no-plaintext.md +21 −12
  • ios/LChatCore/Sources/LChatCore/InviteCrypto.swift +3 −1
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +13 −3
  • ios/LChatCore/Sources/LChatCore/SpaceCreate.swift +27 −2
  • ios/LChatCore/Tests/LChatCoreTests/SpaceCreateTests.swift +20 −0
  • main.go +54 −1
  • test/crypto.test.mjs +35 −0
  • test/invite.test.mjs +47 −12
  • web/static/apps/admin.js +52 −2
  • web/static/crypto.js +40 −0
  • web/static/invite.js +30 −10
  • web/static/join.js +60 −10
  • web/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.");
}