eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// Sealed instance branding under the link-carried branding key
4// (docs/no-plaintext.md). Branding — group name, description, admin identity,
5// the server avatar and its alt — must render on surfaces where no member key
6// exists yet (join cards, native add-chat, the home save card). Every one of
7// those surfaces is reached via a link, so branding can be sealed under a key
8// B that rides the link #fragment (never sent to the server): clients derive
9// an AES key from B (PBKDF2, see crypto.js deriveBrandingKey) and open one
10// blob. Members bootstrap B from a copy sealed under the instance key, so
11// nobody needs to re-visit a link.
12//
13// The server's role here is storage and gating only: it never sees B, never
14// derives, never opens. All ciphertext below is produced client-side by an
15// admin who holds both admin authority and the instance key. Sealing is
16// opt-in per instance and reversible (the client supplies the plaintext back
17// on unseal — the server cannot).
18//
19// Honest scope (also in the doc): B is short and the ciphertext sits on this
20// very server, so sealing defeats bulk exposure of a leaked DB or bucket, not
21// a targeted brute-force by the operator. Member content is never under B.
22
23import (
24 "encoding/json"
25 "net/http"
26 "time"
27)
28
29// sealedAvatarMime marks instance_avatar bytes as branding-key ciphertext
30// (the base64url iv||ct string, stored as bytes). Clients that see this
31// content type fetch the body as text and decrypt with the branding key.
32const sealedAvatarMime = "application/vnd.lchat.sealed"
33
34func (db *DB) brandingSealed() bool {
35 v, _, _ := db.getConfig("branding_sealed")
36 return v == "1"
37}
38
39// sealBranding stores the client-sealed branding set and clears every
40// plaintext counterpart in the same request, flipping the instance to sealed
41// mode. Idempotent: re-posting (a rename, a new photo) overwrites the blobs.
42func (s *Server) sealBranding(w http.ResponseWriter, r *http.Request) {
43 if !s.requireAdmin(w, r) {
44 return
45 }
46 var body struct {
47 // AES-GCM over {name, description, admin_name, admin_email,
48 // avatar_alt, avatar_mime} under the B-derived key.
49 BrandingBlob string `json:"branding_blob"`
50 // PBKDF2 salt for deriving from B — random, not secret.
51 BrandingSalt string `json:"branding_salt"`
52 // B sealed under the instance key, for member bootstrap.
53 BrandingKeySealed string `json:"branding_key_sealed"`
54 // Optional: the avatar bytes sealed under the B-derived key
55 // (base64url iv||ct). Empty keeps the current avatar state.
56 AvatarSealed string `json:"avatar_sealed"`
57 }
58 if json.NewDecoder(r.Body).Decode(&body) != nil {
59 httpError(w, http.StatusBadRequest, "bad request")
60 return
61 }
62 if body.BrandingBlob == "" || len(body.BrandingBlob) > 16*1024 ||
63 body.BrandingSalt == "" || len(body.BrandingSalt) > 128 ||
64 body.BrandingKeySealed == "" || len(body.BrandingKeySealed) > 1024 ||
65 len(body.AvatarSealed) > 2*maxServerAvatarBytes {
66 httpError(w, http.StatusBadRequest, "bad request")
67 return
68 }
69 // The avatar first: a sealed instance must never keep serving plaintext
70 // photo bytes. If the client sends no sealed replacement (it had none, or
71 // its avatar fetch failed mid-seal), an existing PLAINTEXT photo is
72 // cleared rather than left readable behind a "sealed" flag — the admin
73 // re-seals with the photo to bring it back. An already-sealed avatar is
74 // kept (re-seals that only change text omit it).
75 if body.AvatarSealed != "" {
76 if err := s.db.setInstanceAvatar([]byte(body.AvatarSealed), sealedAvatarMime, time.Now().Unix()); err != nil {
77 httpError(w, http.StatusInternalServerError, "db error")
78 return
79 }
80 } else if _, mime, _, ok := s.db.getInstanceAvatar(); ok && mime != sealedAvatarMime {
81 if err := s.db.clearInstanceAvatar(); err != nil {
82 httpError(w, http.StatusInternalServerError, "db error")
83 return
84 }
85 }
86 // Ordered, sealed flag LAST: these writes aren't one transaction, so a
87 // mid-loop failure must never leave branding_sealed=1 beside readable
88 // plaintext — flipping the flag is the final act.
89 for _, kv := range [][2]string{
90 {"branding_blob", body.BrandingBlob},
91 {"branding_salt", body.BrandingSalt},
92 {"branding_key_sealed", body.BrandingKeySealed},
93 // Clear the plaintext the blob replaces. group_name intentionally
94 // falls back to the compiled-in default for keyless surfaces;
95 // groupNameSet() reports true while sealed (see db.go).
96 {"group_name", ""},
97 {"group_description", ""},
98 {"server_avatar_alt", ""},
99 {"admin_name", ""},
100 {"admin_email", ""},
101 {"branding_sealed", "1"},
102 } {
103 if err := s.db.setConfig(kv[0], kv[1]); err != nil {
104 httpError(w, http.StatusInternalServerError, "db error")
105 return
106 }
107 }
108 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
109}
110
111// unsealBranding restores plaintext mode. The server cannot decrypt, so the
112// admin's client (which just decrypted the blob) supplies the plaintext back.
113// The sealed avatar bytes are useless in plaintext mode and are cleared; the
114// admin re-uploads the photo via the normal setServerAvatar path.
115func (s *Server) unsealBranding(w http.ResponseWriter, r *http.Request) {
116 if !s.requireAdmin(w, r) {
117 return
118 }
119 if !s.db.brandingSealed() {
120 httpError(w, http.StatusConflict, "branding is not sealed")
121 return
122 }
123 var body struct {
124 Name string `json:"name"`
125 Description string `json:"description"`
126 AdminName string `json:"admin_name"`
127 AdminEmail string `json:"admin_email"`
128 AvatarAlt string `json:"avatar_alt"`
129 }
130 if json.NewDecoder(r.Body).Decode(&body) != nil {
131 httpError(w, http.StatusBadRequest, "bad request")
132 return
133 }
134 if body.Name == "" || len(body.Name) > 60 {
135 httpError(w, http.StatusBadRequest, "name must be 1–60 characters")
136 return
137 }
138 // Ordered, sealed flag LAST (mirror of sealBranding): restore the
139 // plaintext, drop the blobs, and only then leave sealed mode — a mid-loop
140 // failure keeps the instance sealed and the unseal retryable.
141 for _, kv := range [][2]string{
142 {"group_name", body.Name},
143 {"group_description", body.Description},
144 {"server_avatar_alt", body.AvatarAlt},
145 {"admin_name", body.AdminName},
146 {"admin_email", body.AdminEmail},
147 {"branding_blob", ""},
148 {"branding_salt", ""},
149 {"branding_key_sealed", ""},
150 {"branding_sealed", ""},
151 } {
152 if err := s.db.setConfig(kv[0], kv[1]); err != nil {
153 httpError(w, http.StatusInternalServerError, "db error")
154 return
155 }
156 }
157 if _, mime, _, ok := s.db.getInstanceAvatar(); ok && mime == sealedAvatarMime {
158 if err := s.db.clearInstanceAvatar(); err != nil {
159 httpError(w, http.StatusInternalServerError, "db error")
160 return
161 }
162 }
163 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
164}
165
166// requireUnsealed guards the plaintext branding setters: while sealed, a
167// stale admin surface must not write readable branding back beside the blob.
168// Renames and new photos go through sealBranding again (the client re-seals).
169func (s *Server) requireUnsealed(w http.ResponseWriter) bool {
170 if s.db.brandingSealed() {
171 httpError(w, http.StatusConflict, "branding is sealed — update it via the sealed-branding flow")
172 return false
173 }
174 return true
175}
176