Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions cmd/lattice-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"

Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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
}
1 change: 1 addition & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
58 changes: 58 additions & 0 deletions internal/server/server_plugin_trust.go
Original file line number Diff line number Diff line change
@@ -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
}
107 changes: 107 additions & 0 deletions internal/server/server_plugin_trust_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}