eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "bytes"
5 "encoding/json"
6 "net/http"
7 "strings"
8 "testing"
9)
10
11func sealTestBranding(t *testing.T, ts string) {
12 t.Helper()
13 body, _ := json.Marshal(map[string]string{
14 "branding_blob": "SEALED(branding)",
15 "branding_salt": "salt123",
16 "branding_key_sealed": "WRAPPED(B)",
17 "avatar_sealed": "SEALED(avatar-bytes)",
18 })
19 r := adminReq(t, "POST", ts+"/api/admin/branding/seal", "application/json", body)
20 if r.StatusCode != http.StatusOK {
21 t.Fatalf("seal: %d", r.StatusCode)
22 }
23}
24
25// Sealing stores only ciphertext and clears every plaintext counterpart; the
26// public read serves the blob (openable only by link-holders), the member read
27// adds the instance-key-sealed copy of B, and keyless surfaces fall back to
28// the compiled-in default name.
29func TestBrandingSeal(t *testing.T) {
30 ts, srv := newTestServer(t)
31
32 // Start named + branded in plaintext, as a real instance would.
33 srv.db.setGroupName("Family Chat")
34 srv.db.setGroupDescription("Our cosy corner")
35 srv.db.setAdminName("Paul")
36 srv.db.setAdminEmail("paul@example.com")
37 srv.db.setConfig("server_avatar_alt", "A sunflower")
38
39 sealTestBranding(t, ts.URL)
40
41 // No plaintext branding remains anywhere in config.
42 rows, err := srv.db.sql.Query("SELECT key, value FROM config")
43 if err != nil {
44 t.Fatalf("config: %v", err)
45 }
46 defer rows.Close()
47 for rows.Next() {
48 var k, v string
49 rows.Scan(&k, &v)
50 for _, secret := range []string{"Family Chat", "cosy", "Paul", "paul@example.com", "sunflower"} {
51 if strings.Contains(v, secret) {
52 t.Fatalf("plaintext %q survives sealing in config[%s]", secret, k)
53 }
54 }
55 }
56
57 // Public read: only the sealed marker — no ciphertext for bare visitors
58 // (serving it would let anyone start the offline brute-force, and any past
59 // link-holder open the members-only admin identity). Name fell back to the
60 // default; name_set stays true (the space IS named — just not readably).
61 r, _ := http.Get(ts.URL + "/api/group")
62 var pub map[string]any
63 json.NewDecoder(r.Body).Decode(&pub)
64 if pub["sealed"] != true {
65 t.Fatalf("public group read = %v", pub)
66 }
67 if pub["name"] == "Family Chat" || pub["name_set"] != true {
68 t.Fatalf("sealed name leak / name_set: %v", pub)
69 }
70 for _, k := range []string{"branding_blob", "branding_salt", "branding_key_sealed"} {
71 if _, leak := pub[k]; leak {
72 t.Fatalf("%s served on the public read", k)
73 }
74 }
75
76 // Member read: the blob + salt, plus the sealed B for bootstrap.
77 tok := mintDeviceToken(t, srv.db, "m")
78 r, _ = http.Get(ts.URL + "/api/group?token=" + tok)
79 var mem map[string]any
80 json.NewDecoder(r.Body).Decode(&mem)
81 if mem["branding_blob"] != "SEALED(branding)" || mem["branding_salt"] != "salt123" || mem["branding_key_sealed"] != "WRAPPED(B)" {
82 t.Fatalf("member group read = %v", mem)
83 }
84
85 // The avatar serves the sealed bytes under the sealed content type.
86 r, _ = http.Get(ts.URL + "/api/server-avatar")
87 if ct := r.Header.Get("Content-Type"); ct != sealedAvatarMime {
88 t.Fatalf("avatar content-type = %q", ct)
89 }
90}
91
92// While sealed, the plaintext setters refuse — a stale admin surface must not
93// write readable branding back beside the blob.
94func TestBrandingSealGuardsPlaintextSetters(t *testing.T) {
95 ts, srv := newTestServer(t)
96 srv.db.setGroupName("Before")
97 sealTestBranding(t, ts.URL)
98
99 name, _ := json.Marshal(map[string]string{"name": "Plain Again"})
100 if r := adminReq(t, "POST", ts.URL+"/api/admin/group", "application/json", name); r.StatusCode != http.StatusConflict {
101 t.Fatalf("setGroup while sealed: want 409, got %d", r.StatusCode)
102 }
103 desc, _ := json.Marshal(map[string]string{"description": "readable"})
104 if r := adminReq(t, "POST", ts.URL+"/api/group-description", "application/json", desc); r.StatusCode != http.StatusConflict {
105 t.Fatalf("description while sealed: want 409, got %d", r.StatusCode)
106 }
107 if r := adminReq(t, "POST", ts.URL+"/api/admin/server-avatar", "image/png", []byte("png-bytes")); r.StatusCode != http.StatusConflict {
108 t.Fatalf("avatar while sealed: want 409, got %d", r.StatusCode)
109 }
110 // avatar_alt hides in the settings grab-bag — it must 409 like the rest.
111 alt, _ := json.Marshal(map[string]string{"avatar_alt": "readable alt"})
112 if r := adminReq(t, "POST", ts.URL+"/api/admin/settings", "application/json", alt); r.StatusCode != http.StatusConflict {
113 t.Fatalf("avatar_alt while sealed: want 409, got %d", r.StatusCode)
114 }
115 // Non-branding settings still work while sealed.
116 gate, _ := json.Marshal(map[string]bool{"apps_enabled": true})
117 if r := adminReq(t, "POST", ts.URL+"/api/admin/settings", "application/json", gate); r.StatusCode != http.StatusOK {
118 t.Fatalf("non-branding settings while sealed: want 200, got %d", r.StatusCode)
119 }
120}
121
122// Sealing with no sealed replacement photo must not leave an existing
123// PLAINTEXT photo publicly served behind the sealed flag (the client omits
124// avatar_sealed when it has no photo — or when its avatar fetch failed
125// mid-seal). A re-seal that omits it keeps an already-sealed photo.
126func TestBrandingSealClearsPlaintextAvatar(t *testing.T) {
127 ts, srv := newTestServer(t)
128 if err := srv.db.setInstanceAvatar([]byte("real-jpeg-bytes"), "image/jpeg", 1); err != nil {
129 t.Fatalf("seed avatar: %v", err)
130 }
131
132 body, _ := json.Marshal(map[string]string{
133 "branding_blob": "SEALED(branding)", "branding_salt": "salt123", "branding_key_sealed": "WRAPPED(B)",
134 })
135 if r := adminReq(t, "POST", ts.URL+"/api/admin/branding/seal", "application/json", body); r.StatusCode != http.StatusOK {
136 t.Fatalf("seal: %d", r.StatusCode)
137 }
138 if _, _, _, ok := srv.db.getInstanceAvatar(); ok {
139 t.Fatal("plaintext photo still served on a sealed instance")
140 }
141
142 // Re-seal with a sealed photo, then a text-only re-seal: the photo stays.
143 body, _ = json.Marshal(map[string]string{
144 "branding_blob": "SEALED(branding2)", "branding_salt": "salt123", "branding_key_sealed": "WRAPPED(B)",
145 "avatar_sealed": "SEALED(avatar-bytes)",
146 })
147 if r := adminReq(t, "POST", ts.URL+"/api/admin/branding/seal", "application/json", body); r.StatusCode != http.StatusOK {
148 t.Fatalf("re-seal with photo: %d", r.StatusCode)
149 }
150 body, _ = json.Marshal(map[string]string{
151 "branding_blob": "SEALED(branding3)", "branding_salt": "salt123", "branding_key_sealed": "WRAPPED(B)",
152 })
153 if r := adminReq(t, "POST", ts.URL+"/api/admin/branding/seal", "application/json", body); r.StatusCode != http.StatusOK {
154 t.Fatalf("text-only re-seal: %d", r.StatusCode)
155 }
156 if _, mime, _, ok := srv.db.getInstanceAvatar(); !ok || mime != sealedAvatarMime {
157 t.Fatalf("sealed photo lost on a text-only re-seal (ok=%v mime=%q)", ok, mime)
158 }
159}
160
161// Invite reads carry the blob + salt so the join card (whose link fragment
162// carries B) renders full branding before any key exists.
163func TestBrandingOnKeyInvite(t *testing.T) {
164 ts, srv := newTestServer(t)
165 sealTestBranding(t, ts.URL)
166
167 uid, _ := srv.db.createUser("")
168 if err := srv.db.createKeyInvite("inv-brand", uid, "", testClaimHash, 0); err != nil {
169 t.Fatalf("createKeyInvite: %v", err)
170 }
171 r, _ := http.Get(ts.URL + "/api/key-invite/inv-brand")
172 var resp map[string]any
173 json.NewDecoder(r.Body).Decode(&resp)
174 if resp["ok"] != true || resp["branding_blob"] != "SEALED(branding)" || resp["branding_salt"] != "salt123" {
175 t.Fatalf("key-invite read = %v", resp)
176 }
177}
178
179// Unseal restores plaintext mode from client-supplied values (the server
180// cannot decrypt) and clears the sealed rows + the now-useless sealed avatar.
181func TestBrandingUnseal(t *testing.T) {
182 ts, srv := newTestServer(t)
183 sealTestBranding(t, ts.URL)
184
185 body, _ := json.Marshal(map[string]string{
186 "name": "Family Chat", "description": "Back in the clear",
187 "admin_name": "Paul", "admin_email": "paul@example.com", "avatar_alt": "",
188 })
189 if r := adminReq(t, "POST", ts.URL+"/api/admin/branding/unseal", "application/json", body); r.StatusCode != http.StatusOK {
190 t.Fatalf("unseal: %d", r.StatusCode)
191 }
192 if srv.db.brandingSealed() {
193 t.Fatal("still sealed after unseal")
194 }
195 if got := srv.db.getGroupName(); got != "Family Chat" {
196 t.Fatalf("name after unseal = %q", got)
197 }
198 if _, _, _, ok := srv.db.getInstanceAvatar(); ok {
199 t.Fatal("sealed avatar bytes survived unseal")
200 }
201 // Unsealing twice conflicts (nothing to unseal).
202 if r := adminReq(t, "POST", ts.URL+"/api/admin/branding/unseal", "application/json", body); r.StatusCode != http.StatusConflict {
203 t.Fatalf("double unseal: want 409, got %d", r.StatusCode)
204 }
205}
206
207// Sealing is admin-only.
208func TestBrandingSealRequiresAdmin(t *testing.T) {
209 ts, _ := newTestServer(t)
210 body, _ := json.Marshal(map[string]string{
211 "branding_blob": "x", "branding_salt": "y", "branding_key_sealed": "z",
212 })
213 req, _ := http.NewRequest("POST", ts.URL+"/api/admin/branding/seal", bytes.NewReader(body))
214 req.Header.Set("Authorization", "Bearer wrong")
215 r, err := http.DefaultClient.Do(req)
216 if err != nil {
217 t.Fatalf("do: %v", err)
218 }
219 if r.StatusCode == http.StatusOK {
220 t.Fatal("non-admin sealed branding")
221 }
222}
223