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") } }