From 45e5ebf394d6790ae0b349000427312c885f7860 Mon Sep 17 00:00:00 2001 From: lr00rl Date: Tue, 28 Jul 2026 02:39:21 -0700 Subject: [PATCH 1/2] Report whether this server trusts anything beyond the official publisher TASK-0012's server half, shaped by athena's proposal after she checked the thing that would have broken: /api/plugin-contributions returns a BARE ARRAY and the dashboard consumes PluginView[], so carrying a sibling field there would break every current client. New read-only GET /api/plugin-trust instead, withAuth("") because the banner must appear for whoever is looking at the screen, not only for admins. Two properties she asked for explicitly, both now pinned by tests: - names, never keys. The operator needs to know WHICH publisher is trusted, never its value. Proved by breaking it: leaking the key makes the test print it. - absence must never look like safety. The object is ALWAYS emitted, including "non_official": false, so a banner gated on a field's presence cannot confuse "trust is normal" with "the server never told me". allow_unsigned_host_risk raises the condition on its own: disabling signature enforcement is categorically worse than one extra trusted publisher. --- internal/server/server.go | 1 + internal/server/server_plugin_trust.go | 58 +++++++++++ internal/server/server_plugin_trust_test.go | 107 ++++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 internal/server/server_plugin_trust.go create mode 100644 internal/server/server_plugin_trust_test.go diff --git a/internal/server/server.go b/internal/server/server.go index c14839b..9f7cd5f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -835,6 +835,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/api/audit/verify", s.withAuth("audit:read", s.handleAuditVerify)) mux.HandleFunc("/api/plugins", s.withAuth("audit:read", s.handlePlugins)) mux.HandleFunc("/api/plugin-contributions", s.withAuth("", s.handlePluginContributions)) + mux.HandleFunc("/api/plugin-trust", s.withAuth("", s.handlePluginTrust)) mux.HandleFunc("/api/plugins/lifecycle", s.withAuth("plugin:admin", s.handlePluginLifecycle)) mux.HandleFunc("/api/plugins/verify", s.withAuth("plugin:verify", s.handlePluginVerify)) mux.HandleFunc("/api/plugins/invoke", s.withAuth("plugin:admin", s.handlePluginInvoke)) diff --git a/internal/server/server_plugin_trust.go b/internal/server/server_plugin_trust.go new file mode 100644 index 0000000..47c78c8 --- /dev/null +++ b/internal/server/server_plugin_trust.go @@ -0,0 +1,58 @@ +package server + +import ( + "errors" + "net/http" + "sort" +) + +// officialPublisher is the project's own publisher. Anything else being trusted is +// the condition the dashboard must announce — not a "dev mode", which does not exist +// (TASK-0011 Decision 2: production refusal is structural, not a switch). +const officialPublisher = "latticenet" + +type pluginTrustView struct { + // NonOfficial is true when this server trusts any publisher besides the project's + // own, or when signature enforcement for host-risk plugins has been switched off. + NonOfficial bool `json:"non_official"` + // Publishers lists the NAMES of trusted non-official publishers. Never key + // material: an operator needs to know WHICH key is trusted, never its value. + Publishers []string `json:"publishers"` + // AllowUnsignedHostRisk is surfaced separately because it is categorically worse + // than an extra trusted publisher — it disables signature enforcement outright. + AllowUnsignedHostRisk bool `json:"allow_unsigned_host_risk"` +} + +// handlePluginTrust reports whether this server's trust is anything other than +// "the project's own publisher, signatures enforced". +// +// It ALWAYS emits the object, including the boring case. A dashboard that renders a +// banner only when a field is present would show nothing against an older server that +// omits it — and "no banner" would then mean both "trust is normal" and "the server +// never told me". Absence must never be able to look like safety, so the answer "no" +// is stated rather than implied by silence. +// +// Readable by any authenticated operator (withAuth "") on purpose: the banner has to +// appear for whoever is looking at the screen, not only for admins. +func (s *Server) handlePluginTrust(w http.ResponseWriter, r *http.Request, _ principal) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, errors.New("method not allowed")) + return + } + writeJSON(w, http.StatusOK, s.pluginTrustView()) +} + +func (s *Server) pluginTrustView() pluginTrustView { + view := pluginTrustView{ + Publishers: []string{}, + AllowUnsignedHostRisk: s.pluginTrust.AllowUnsignedHostRisk, + } + for name := range s.pluginTrust.TrustedPublishers { + if name != officialPublisher { + view.Publishers = append(view.Publishers, name) + } + } + sort.Strings(view.Publishers) + view.NonOfficial = len(view.Publishers) > 0 || view.AllowUnsignedHostRisk + return view +} diff --git a/internal/server/server_plugin_trust_test.go b/internal/server/server_plugin_trust_test.go new file mode 100644 index 0000000..e39ab50 --- /dev/null +++ b/internal/server/server_plugin_trust_test.go @@ -0,0 +1,107 @@ +package server + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/LatticeNet/lattice-server/internal/plugin" + "github.com/LatticeNet/lattice-server/internal/store" +) + +func trustView(t *testing.T, policy plugin.TrustPolicy) (pluginTrustView, string) { + t.Helper() + st, err := store.Open("") + if err != nil { + t.Fatal(err) + } + srv, err := New(Options{Store: st, AdminPassword: testAdminPass, DisableRenewalScheduler: true, PluginTrust: policy}) + if err != nil { + t.Fatalf("New: %v", err) + } + handler := srv.Handler() + cookies, csrf := loginSession(t, handler) + resp := doJSON(t, handler, http.MethodGet, "/api/plugin-trust", "", cookies, csrf) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status: want 200, got %d", resp.StatusCode) + } + raw, _ := io.ReadAll(resp.Body) + var view pluginTrustView + if err := json.Unmarshal(raw, &view); err != nil { + t.Fatalf("decode: %v (%s)", err, raw) + } + return view, string(raw) +} + +// The boring case must be STATED, not implied by silence: a dashboard that renders a +// banner only when a field appears would show nothing against a server that never +// answered, and "nothing" would then mean both "trust is normal" and "I do not know". +func TestPluginTrustAlwaysAnswersEvenWhenNormal(t *testing.T) { + pub, _, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + view, raw := trustView(t, plugin.TrustPolicy{TrustedPublishers: map[string]ed25519.PublicKey{officialPublisher: pub}}) + if view.NonOfficial { + t.Fatalf("official-only trust reported as non-official: %s", raw) + } + if !strings.Contains(raw, `"non_official":false`) { + t.Fatalf("the negative answer must be present in the payload, not omitted: %s", raw) + } + if len(view.Publishers) != 0 { + t.Fatalf("publishers should be empty: %v", view.Publishers) + } +} + +// A dev key trusted alongside the official one is exactly the condition the banner +// exists for, and the operator needs to know WHICH key it is. +func TestPluginTrustNamesNonOfficialPublishers(t *testing.T) { + pub, _, _ := ed25519.GenerateKey(nil) + dev, _, _ := ed25519.GenerateKey(nil) + view, _ := trustView(t, plugin.TrustPolicy{TrustedPublishers: map[string]ed25519.PublicKey{ + officialPublisher: pub, "devkey-somebody": dev, + }}) + if !view.NonOfficial { + t.Fatal("a trusted non-official publisher must set non_official") + } + if len(view.Publishers) != 1 || view.Publishers[0] != "devkey-somebody" { + t.Fatalf("publishers: want [devkey-somebody], got %v", view.Publishers) + } +} + +// Key material must never leave the server through this surface: the banner needs the +// name of the publisher, never its key. +func TestPluginTrustNeverLeaksKeyMaterial(t *testing.T) { + pub, _, _ := ed25519.GenerateKey(nil) + dev, _, _ := ed25519.GenerateKey(nil) + _, raw := trustView(t, plugin.TrustPolicy{TrustedPublishers: map[string]ed25519.PublicKey{ + officialPublisher: pub, "devkey-somebody": dev, + }}) + for _, key := range []ed25519.PublicKey{pub, dev} { + encoded := base64.StdEncoding.EncodeToString(key) + if strings.Contains(raw, encoded) { + t.Fatalf("public key material leaked into the trust payload: %s", raw) + } + if strings.Contains(raw, string(key)) { + t.Fatalf("raw key bytes leaked into the trust payload") + } + } +} + +// Disabling signature enforcement is categorically worse than an extra publisher and +// must raise the condition on its own, even with no unofficial publisher present. +func TestPluginTrustFlagsUnsignedHostRiskOnItsOwn(t *testing.T) { + pub, _, _ := ed25519.GenerateKey(nil) + view, _ := trustView(t, plugin.TrustPolicy{ + TrustedPublishers: map[string]ed25519.PublicKey{officialPublisher: pub}, + AllowUnsignedHostRisk: true, + }) + if !view.NonOfficial || !view.AllowUnsignedHostRisk { + t.Fatalf("allow_unsigned_host_risk must raise the condition by itself: %+v", view) + } +} From a84c3fe2b446717293b9712b13eeca7ce51de69c Mon Sep 17 00:00:00 2001 From: lr00rl Date: Tue, 28 Jul 2026 02:48:19 -0700 Subject: [PATCH 2/2] Warn at startup when a non-official publisher is trusted The endpoint tells the dashboard; this tells a server nobody is looking at. Two independent surfaces for one condition, and the log follows the same rule as the payload: names only, never key material. --- cmd/lattice-server/main.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/cmd/lattice-server/main.go b/cmd/lattice-server/main.go index 4188a63..7157546 100644 --- a/cmd/lattice-server/main.go +++ b/cmd/lattice-server/main.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "path/filepath" + "sort" "strings" "time" @@ -103,6 +104,12 @@ func main() { if trustPolicy.AllowUnsignedHostRisk { log.Printf("WARNING: plugin trust policy sets allow_unsigned_host_risk=true; UNSIGNED host-risk plugins will load. Do not use in production.") } + // The dashboard announces this too (GET /api/plugin-trust), but a server nobody is + // looking at must still say it. Two independent surfaces, one condition. + if names := nonOfficialPublishers(trustPolicy); len(names) > 0 { + log.Printf("WARNING: plugin trust policy trusts non-official publisher(s) %s; bundles signed by them will load. Production trusts only the project publisher.", + strings.Join(names, ", ")) + } dataDir := "" if dataPath != "" { @@ -299,3 +306,16 @@ func loadPluginTrust(path string) (plugin.TrustPolicy, error) { } return plugin.ParseTrustPolicyJSON(data) } + +// nonOfficialPublishers lists trusted publishers other than the project's own, by NAME. +// Key material never appears in a log line, the same rule the trust endpoint follows. +func nonOfficialPublishers(policy plugin.TrustPolicy) []string { + var names []string + for name := range policy.TrustedPublishers { + if name != "latticenet" { + names = append(names, name) + } + } + sort.Strings(names) + return names +}