| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | ) |
| 10 | |
| 11 | func 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. |
| 29 | func 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. |
| 94 | func 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. |
| 126 | func 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. |
| 163 | func 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. |
| 181 | func 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. |
| 208 | func 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 | |