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 }