From 75379e2697788754f1abcf23afd149efba3d1a2c Mon Sep 17 00:00:00 2001 From: lr00rl Date: Tue, 14 Jul 2026 05:37:57 -0700 Subject: [PATCH] feat: give plugins an encrypted secret store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §9.4. A plugin that manages WireGuard keys or provisions credentials has nowhere safe to put them today: the only namespaced storage it can reach is KV, which is plaintext at rest AND readable over GET /api/kv by any principal holding kv:read. A private key written there is a private key in cleartext, in a place a browser can fetch. secret:read and secret:write are new host-risk capabilities backed by a separate encrypted collection. They are host-risk because the value a plugin stores here is exactly the material an attacker wants, and because secret:read on a compromised plugin reads back everything it ever wrote. They are deliberately absent from hostRiskExemptForNonSystem and from workerCapabilities: the capability model enforces system-only by OMISSION, and unlike guarded egress there is no broker check that would make handing a sandboxed third party a key vault safe. The vault is a distinct State collection, not a corner of KV. Sharing the map would have inherited both of KV's disqualifying properties, and a plugin that confused the two would silently write a key into cleartext storage. Distinct bucket prefixes make that impossible to do by accident. Encryption is not a property of the store type — it is a property of three functions. A new State map is serialized into state.json by default, so adding one and stopping there writes every plugin credential to disk in the clear, and neither the type system nor any existing test would notice. So the collection is wired through encryptedState, decryptState, AND stateHasEnvelope: the last keeps the lost-master-key guard honest, since without it a missing key degrades to handing envelope strings back to plugins as if they were secrets. A test reads state.json back and asserts the plaintext is absent and the stored value is a real envelope. Leak channels are closed at each end. secret.get returns base64 only, because kv.get's raw string field is what ends up %v-logged or folded into an error. Errors carry key names and never values, since broker error text becomes the audit reason. The capabilities are NOT operator RBAC scopes and there is no HTTP handler, so no token can be granted a path to the vault; both are pinned by tests, one of which fails if any file outside the host adapter so much as references the store. Bounded where KV is not: 64 KiB per secret, 256 per plugin. Every write re-encrypts and rewrites the whole state file, so an unbounded vault is both a disk amplifier and a way for one plugin to slow every other plugin's persistence. Tests: go vet clean; all internal packages green. --- internal/plugin/broker.go | 105 +++++++++- internal/plugin/plugin.go | 10 + internal/plugin/secret_test.go | 197 ++++++++++++++++++ internal/plugin/system_runner.go | 47 +++++ internal/server/plugin_host.go | 71 ++++++- .../server/plugin_secret_isolation_test.go | 55 +++++ internal/store/bolt_state.go | 8 + internal/store/crypto.go | 53 +++++ internal/store/plugin_secret_test.go | 158 ++++++++++++++ internal/store/store.go | 79 ++++++- 10 files changed, 774 insertions(+), 9 deletions(-) create mode 100644 internal/plugin/secret_test.go create mode 100644 internal/server/plugin_secret_isolation_test.go create mode 100644 internal/store/plugin_secret_test.go diff --git a/internal/plugin/broker.go b/internal/plugin/broker.go index bcd5daf..ba52091 100644 --- a/internal/plugin/broker.go +++ b/internal/plugin/broker.go @@ -20,6 +20,8 @@ const ( capNotifySend = "notify:send" capRPCCall = "rpc:call" capRPCExpose = "rpc:expose" + capSecretRead = "secret:read" + capSecretWrite = "secret:write" // kvBucketPrefix is prepended to a plugin id to derive the fixed, // server-visible KV bucket a plugin is confined to. The plugin never gets to @@ -28,6 +30,18 @@ const ( // as a confused deputy against the shared operator KV store. kvBucketPrefix = "plugin:" + // secretBucketPrefix is the equivalent pin for encrypted secret storage. It is a + // SEPARATE namespace from kvBucketPrefix on purpose: KV is plaintext at rest, so + // a plugin that confused one for the other would silently write a private key + // into cleartext storage. Distinct prefixes make that impossible to do by + // accident — a secret written through the secret host call can only ever land in + // the encrypted collection. + secretBucketPrefix = "pluginsecret:" + + // secretMaxValueBytes caps one stored secret. Credentials and private keys are + // small; a large value here is a sign of misuse, not of a legitimate secret. + secretMaxValueBytes = 64 * 1024 + // logMaxMessageBytes caps a plugin-authored log message. Anything longer is // truncated (with a marker) so a plugin cannot flood the operator log sink. logMaxMessageBytes = 8 * 1024 @@ -73,7 +87,11 @@ func (e *CapabilityError) Unwrap() error { // HostServices are the real server-owned handles exposed through the broker. // The broker keeps these handles behind per-call capability checks. type HostServices struct { - KV KVHost + KV KVHost + // Secret is the encrypted namespaced store (spec §9.4). When nil, a plugin + // holding secret:read/secret:write gets ErrHostServiceUnavailable rather than + // silently falling back to plaintext KV. + Secret SecretHost Notify NotifyHost HTTP HTTPHost OperatorHTTP OperatorHTTPHost @@ -98,6 +116,20 @@ type KVHost interface { Put(ctx context.Context, key string, value []byte) error } +// SecretHost is the plugin-facing encrypted-secret subset (spec §9.4). It is shaped +// like KVHost on purpose, but the implementation stores values through the server's +// at-rest cipher. The distinction is not a naming convention: a value written here is +// encrypted in the persisted state, and a value written through KVHost is not. +// +// There is no List. A plugin reads back a key it chose to write; it cannot enumerate +// its own vault, so a read-only compromise cannot sweep for secrets whose names it +// does not already know. +type SecretHost interface { + Get(ctx context.Context, key string) (string, bool, error) + Put(ctx context.Context, key, value string) error + Delete(ctx context.Context, key string) error +} + // NotifyHost sends an operator notification through server-owned channels. type NotifyHost interface { Send(ctx context.Context, title, body string) error @@ -176,6 +208,9 @@ type Broker struct { // broker pins every KV access to this bucket so the plugin can never reach // another bucket in the shared operator KV store. kvBucket string + // secretBucket is the fixed, per-plugin ENCRYPTED namespace + // ("pluginsecret:"), pinned by the broker exactly as kvBucket is. + secretBucket string // guardURL guards every outbound HTTP target before the broker delegates to // the HTTPHost. It is always non-nil after NewBroker (it defaults to the // built-in outbound guard) so egress filtering is structural, not by @@ -253,6 +288,7 @@ func NewBroker(loaded Loaded, services HostServices) (*Broker, error) { capabilities: make(map[string]struct{}, len(caps)), services: services, kvBucket: kvBucketPrefix + loaded.Manifest.ID, + secretBucket: secretBucketPrefix + loaded.Manifest.ID, guardURL: guard, guardOperatorURL: operatorGuard, } @@ -332,6 +368,73 @@ func (b *Broker) scopedKVKey(key string) (string, error) { return b.kvBucket + "/" + key, nil } +// SecretGet reads an encrypted secret and requires secret:read. As with KV, the +// bucket is pinned by the broker, so a plugin can only read secrets it wrote itself. +// +// The value is returned to the PLUGIN BACKEND only. It never crosses the browser +// bridge: the bridge can invoke a plugin's declared interface methods, and what a +// method chooses to return is the plugin's own business, but the host never places a +// secret into a plan, an error, an audit record, or a log line. +func (b *Broker) SecretGet(ctx context.Context, key string) (string, bool, error) { + if err := b.require(ctx, "secret.get", capSecretRead); err != nil { + return "", false, err + } + if b.services.Secret == nil { + return "", false, fmt.Errorf("%w: secret", ErrHostServiceUnavailable) + } + scoped, err := b.scopedSecretKey(key) + if err != nil { + return "", false, err + } + return b.services.Secret.Get(ctx, scoped) +} + +// SecretPut writes an encrypted secret and requires secret:write. +func (b *Broker) SecretPut(ctx context.Context, key, value string) error { + if err := b.require(ctx, "secret.put", capSecretWrite); err != nil { + return err + } + if b.services.Secret == nil { + return fmt.Errorf("%w: secret", ErrHostServiceUnavailable) + } + if len(value) > secretMaxValueBytes { + return fmt.Errorf("plugin secret value exceeds %d bytes", secretMaxValueBytes) + } + scoped, err := b.scopedSecretKey(key) + if err != nil { + return err + } + return b.services.Secret.Put(ctx, scoped, value) +} + +// SecretDelete removes an encrypted secret and requires secret:write. +func (b *Broker) SecretDelete(ctx context.Context, key string) error { + if err := b.require(ctx, "secret.delete", capSecretWrite); err != nil { + return err + } + if b.services.Secret == nil { + return fmt.Errorf("%w: secret", ErrHostServiceUnavailable) + } + scoped, err := b.scopedSecretKey(key) + if err != nil { + return err + } + return b.services.Secret.Delete(ctx, scoped) +} + +// scopedSecretKey pins the bucket exactly as scopedKVKey does. The plugin chooses +// only the entry name, and a "/" in it would let it smuggle a bucket and escape its +// namespace — here that would mean reading another plugin's private keys. +func (b *Broker) scopedSecretKey(key string) (string, error) { + if key == "" { + return "", errors.New("plugin secret key must not be empty") + } + if strings.ContainsAny(key, "/\\") { + return "", errors.New("plugin secret key must not contain a slash") + } + return b.secretBucket + "/" + key, nil +} + // Notify sends an operator notification and requires notify:send. func (b *Broker) Notify(ctx context.Context, title, body string) error { if err := b.require(ctx, "notify.send", capNotifySend); err != nil { diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 8c43714..bf7e6a1 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -99,6 +99,16 @@ var capabilityRisk = map[string]string{ // server-owned registry's directed allow-list, and the audit log. "rpc:call": RiskHost, "rpc:expose": RiskHost, + // Encrypted plugin secret storage (spec §9.4). Reversible credentials, private + // keys, and generated tokens are held here rather than in KV, which is plaintext + // at rest. Both are host-risk — so system-only, and signed by a trusted publisher + // — because the value a plugin stores here is exactly the material an attacker + // wants, and because secret:read on a compromised plugin reads back everything it + // ever wrote. Deliberately absent from hostRiskExemptForNonSystem: unlike guarded + // egress, there is no broker check that makes handing a sandboxed third party a + // key vault safe. + "secret:read": RiskHost, + "secret:write": RiskHost, } // hostRiskExemptForNonSystem lists host-risk capabilities that non-system plugins diff --git a/internal/plugin/secret_test.go b/internal/plugin/secret_test.go new file mode 100644 index 0000000..f93c4e2 --- /dev/null +++ b/internal/plugin/secret_test.go @@ -0,0 +1,197 @@ +package plugin + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "errors" + "strings" + "testing" +) + +type fakeSecretHost struct { + values map[string]string + err error +} + +func (h *fakeSecretHost) Get(_ context.Context, key string) (string, bool, error) { + if h.err != nil { + return "", false, h.err + } + v, ok := h.values[key] + return v, ok, nil +} + +func (h *fakeSecretHost) Put(_ context.Context, key, value string) error { + if h.err != nil { + return h.err + } + h.values[key] = value + return nil +} + +func (h *fakeSecretHost) Delete(_ context.Context, key string) error { + if h.err != nil { + return h.err + } + delete(h.values, key) + return nil +} + +func secretBroker(t *testing.T, capabilities ...string) (*Broker, *fakeSecretHost) { + t.Helper() + host := &fakeSecretHost{values: map[string]string{}} + broker, err := NewBroker(Loaded{ + Manifest: Manifest{ + ID: "latticenet.wireguard", Name: "WG", Type: TypeSystem, Capabilities: capabilities, + }, + Capabilities: capabilities, + }, HostServices{Secret: host}) + if err != nil { + t.Fatalf("NewBroker: %v", err) + } + return broker, host +} + +// The broker pins the bucket, so a plugin can only ever reach its own vault. A key +// carrying a slash would otherwise let it name a bucket and read another plugin's +// private keys — the same confused-deputy escape the KV namespace closes. +func TestSecretKeyIsPinnedToThePluginsOwnVault(t *testing.T) { + broker, host := secretBroker(t, "secret:read", "secret:write") + ctx := context.Background() + + if err := broker.SecretPut(ctx, "node-a.privkey", "wg-secret"); err != nil { + t.Fatal(err) + } + if _, ok := host.values["pluginsecret:latticenet.wireguard/node-a.privkey"]; !ok { + t.Fatalf("secret was not written under the plugin's pinned bucket: %v", host.values) + } + + for _, escape := range []string{ + "../latticenet.vpn-core/key", + "pluginsecret:latticenet.vpn-core/key", + "a/b", + "a\\b", + "", + } { + if err := broker.SecretPut(ctx, escape, "x"); err == nil { + t.Fatalf("key %q escaped the plugin's namespace", escape) + } + if _, _, err := broker.SecretGet(ctx, escape); err == nil { + t.Fatalf("key %q escaped the plugin's namespace on read", escape) + } + } +} + +func TestSecretCallsRequireTheMatchingCapability(t *testing.T) { + ctx := context.Background() + + readOnly, _ := secretBroker(t, "secret:read") + if err := readOnly.SecretPut(ctx, "k", "v"); !errors.Is(err, ErrCapabilityDenied) { + t.Fatalf("secret:read must not grant writes, got %v", err) + } + if err := readOnly.SecretDelete(ctx, "k"); !errors.Is(err, ErrCapabilityDenied) { + t.Fatalf("secret:read must not grant deletes, got %v", err) + } + + writeOnly, _ := secretBroker(t, "secret:write") + if _, _, err := writeOnly.SecretGet(ctx, "k"); !errors.Is(err, ErrCapabilityDenied) { + t.Fatalf("secret:write must not grant reads, got %v", err) + } + + // Holding some other capability grants nothing here. + unrelated, _ := secretBroker(t, "kv:read") + if _, _, err := unrelated.SecretGet(ctx, "k"); !errors.Is(err, ErrCapabilityDenied) { + t.Fatalf("a plugin without secret:read must be denied, got %v", err) + } + if err := unrelated.SecretPut(ctx, "k", "v"); !errors.Is(err, ErrCapabilityDenied) { + t.Fatalf("a plugin without secret:write must be denied, got %v", err) + } +} + +// A granted capability with no wired service must fail closed rather than silently +// falling back to anything else. +func TestSecretWithoutHostServiceFailsClosed(t *testing.T) { + broker, err := NewBroker(Loaded{ + Manifest: Manifest{ + ID: "latticenet.wireguard", Name: "WG", Type: TypeSystem, + Capabilities: []string{"secret:read"}, + }, + Capabilities: []string{"secret:read"}, + }, HostServices{}) + if err != nil { + t.Fatal(err) + } + if _, _, err := broker.SecretGet(context.Background(), "k"); !errors.Is(err, ErrHostServiceUnavailable) { + t.Fatalf("want ErrHostServiceUnavailable, got %v", err) + } +} + +// secret:read/secret:write are host-risk. Unlike guarded egress, there is no broker +// check that makes handing a sandboxed third party a key vault safe, so they must be +// confined to system plugins — which the capability model enforces by OMITTING them +// from hostRiskExemptForNonSystem and workerCapabilities. +func TestSecretCapabilitiesAreSystemPluginsOnly(t *testing.T) { + for _, pluginType := range []string{TypeWasm, TypeWorker} { + for _, capability := range []string{"secret:read", "secret:write"} { + err := ValidateManifest(Manifest{ + ID: "third.party", Name: "Third party", Type: pluginType, Version: "1", + Capabilities: []string{capability}, + }) + if err == nil { + t.Fatalf("%s plugin must not be able to declare %q", pluginType, capability) + } + if !strings.Contains(err.Error(), "system plugin") && !strings.Contains(err.Error(), "worker") { + t.Fatalf("unexpected rejection reason for %s/%s: %v", pluginType, capability, err) + } + } + } + if err := ValidateManifest(Manifest{ + ID: "latticenet.wireguard", Name: "WG", Type: TypeSystem, Version: "1", + Capabilities: []string{"secret:read", "secret:write"}, + }); err != nil { + t.Fatalf("a system plugin may hold the secret capabilities: %v", err) + } +} + +// AllowUnsignedHostRisk is a dev-only escape hatch for host-risk capabilities. It must +// not reach a v2 plugin: a bundle that can read a key vault has to be signed by a +// publisher the operator explicitly trusts, escape hatch or not. +func TestSecretHoldingV2ManifestIsSignatureRequiredEvenWithUnsignedHostRiskAllowed(t *testing.T) { + m := Manifest{ + Schema: ManifestSchemaV2, ID: "latticenet.wireguard", Name: "WG", Type: TypeSystem, + Version: "0.1.0-alpha.8", Publisher: "latticenet", + Capabilities: []string{"secret:read"}, + Bundle: &BundleSpec{Format: BundleFormatTarGzip, DigestSHA256: strings.Repeat("a", 64)}, + Runtime: &RuntimeSpec{Protocol: RuntimeProtocolStdioJSONV1, Entrypoints: map[string]string{ + "linux/amd64": "bin/linux-amd64/plugin", + }}, + Compatibility: &CompatibilitySpec{Server: ">=0.2.1", DashboardHost: ">=1", RuntimeProtocol: ">=1"}, + Interfaces: []InterfaceContract{{ + Service: "latticenet.wireguard/networks", Backing: BackingCore, + MethodSpecs: []InterfaceMethod{{Name: "overview", Effect: InterfaceEffectRead, Scopes: []string{"node:read"}}}, + }}, + } + policy := TrustPolicy{ + TrustedPublishers: map[string]ed25519.PublicKey{}, + AllowUnsignedHostRisk: true, + } + if err := VerifyManifest(m, nil, policy); err == nil { + t.Fatal("an unsigned v2 manifest holding secret:read must be rejected even when AllowUnsignedHostRisk is set") + } + + // With a trusted publisher it verifies, proving the rejection above was the + // signature requirement and not some unrelated validation failure. + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + artifact := []byte("bundle") + m.Bundle.DigestSHA256 = DigestSHA256(artifact) + m.SignatureEd25519 = base64.RawStdEncoding.EncodeToString(ed25519.Sign(priv, SigningPayload(m))) + policy.TrustedPublishers["latticenet"] = pub + if err := VerifyManifest(m, artifact, policy); err != nil { + t.Fatalf("a signed, trusted v2 manifest holding secret:read must verify: %v", err) + } +} diff --git a/internal/plugin/system_runner.go b/internal/plugin/system_runner.go index 9da4ee8..c222d2b 100644 --- a/internal/plugin/system_runner.go +++ b/internal/plugin/system_runner.go @@ -588,6 +588,53 @@ func dispatchHostCall(ctx context.Context, broker *Broker, call systemHostCall) return nil, err } return json.RawMessage(`{}`), nil + case "secret.get": + var req struct { + Key string `json:"key"` + } + if err := json.Unmarshal(call.Params, &req); err != nil { + return nil, fmt.Errorf("secret.get params: %w", err) + } + value, ok, err := broker.SecretGet(ctx, req.Key) + if err != nil { + return nil, err + } + // Base64 only. kv.get returns the raw string alongside the encoded one, and a + // raw secret field is exactly what gets accidentally %v-logged or folded into + // an error message somewhere downstream. One encoding, and it is not readable + // by eye. + return json.Marshal(struct { + OK bool `json:"ok"` + ValueBase64 string `json:"value_base64,omitempty"` + }{OK: ok, ValueBase64: base64.StdEncoding.EncodeToString([]byte(value))}) + case "secret.put": + var req struct { + Key string `json:"key"` + ValueBase64 string `json:"value_base64"` + } + if err := json.Unmarshal(call.Params, &req); err != nil { + return nil, fmt.Errorf("secret.put params: %w", err) + } + decoded, err := base64.StdEncoding.DecodeString(req.ValueBase64) + if err != nil { + // Report the failure, never the payload that caused it. + return nil, errors.New("secret.put value_base64 is not valid base64") + } + if err := broker.SecretPut(ctx, req.Key, string(decoded)); err != nil { + return nil, err + } + return json.RawMessage(`{}`), nil + case "secret.delete": + var req struct { + Key string `json:"key"` + } + if err := json.Unmarshal(call.Params, &req); err != nil { + return nil, fmt.Errorf("secret.delete params: %w", err) + } + if err := broker.SecretDelete(ctx, req.Key); err != nil { + return nil, err + } + return json.RawMessage(`{}`), nil case "notify.send": var req struct { Title string `json:"title"` diff --git a/internal/server/plugin_host.go b/internal/server/plugin_host.go index ce56668..06fdbe1 100644 --- a/internal/server/plugin_host.go +++ b/internal/server/plugin_host.go @@ -29,7 +29,12 @@ type pluginHost struct { func (s *Server) pluginHostServices() plugin.HostServices { host := &pluginHost{server: s} return plugin.HostServices{ - KV: host, + KV: host, + // Secrets get their own host type rather than another method on pluginHost. + // The two vaults must never be reachable through one another by a typo: KV is + // plaintext at rest, the secret store is encrypted, and a value written to the + // wrong one is a private key in cleartext. + Secret: &pluginSecretHost{server: s}, Notify: host, HTTP: host, OperatorHTTP: host, @@ -39,6 +44,70 @@ func (s *Server) pluginHostServices() plugin.HostServices { } } +// pluginSecretHost implements plugin.SecretHost over the store's encrypted collection +// (spec §9.4). Every method resolves the plugin-pinned composite key, so a plugin can +// only ever reach its own vault. +type pluginSecretHost struct{ server *Server } + +func (h *pluginSecretHost) Get(_ context.Context, key string) (string, bool, error) { + bucket, entryKey, err := splitPluginSecretKey(key) + if err != nil { + return "", false, err + } + entry, ok := h.server.store.PluginSecret(bucket, entryKey) + if !ok { + return "", false, nil + } + return entry.Value, true, nil +} + +func (h *pluginSecretHost) Put(_ context.Context, key, value string) error { + bucket, entryKey, err := splitPluginSecretKey(key) + if err != nil { + return err + } + // The error deliberately carries the key name and never the value: this text + // reaches the broker's audit record and the plugin's own error channel. + if err := h.server.store.PutPluginSecret(model.KVEntry{Bucket: bucket, Key: entryKey, Value: value}); err != nil { + return fmt.Errorf("store plugin secret %q: %w", entryKey, err) + } + return nil +} + +func (h *pluginSecretHost) Delete(_ context.Context, key string) error { + bucket, entryKey, err := splitPluginSecretKey(key) + if err != nil { + return err + } + return h.server.store.DeletePluginSecret(bucket, entryKey) +} + +// pluginSecretBucketPrefix is the namespace every plugin secret access must live +// under. As with KV, the broker pins it and the host re-checks it, so a hand-crafted +// composite key cannot resolve into another plugin's vault. +const pluginSecretBucketPrefix = "pluginsecret:" + +func splitPluginSecretKey(key string) (string, string, error) { + bucket, entryKey, ok := strings.Cut(key, "/") + if !ok { + return "", "", errors.New("plugin secret key must be bucket/key") + } + pluginID, ok := strings.CutPrefix(bucket, pluginSecretBucketPrefix) + if !ok { + return "", "", errors.New("plugin secret bucket must be namespaced to the plugin") + } + if err := validateStorageName(pluginID); err != nil { + return "", "", fmt.Errorf("plugin id: %w", err) + } + if err := validateStorageName(bucket); err != nil { + return "", "", fmt.Errorf("bucket: %w", err) + } + if err := validateStorageName(entryKey); err != nil { + return "", "", fmt.Errorf("key: %w", err) + } + return bucket, entryKey, nil +} + func (h *pluginHost) Get(ctx context.Context, key string) ([]byte, bool, error) { bucket, entryKey, err := splitPluginKVKey(key) if err != nil { diff --git a/internal/server/plugin_secret_isolation_test.go b/internal/server/plugin_secret_isolation_test.go new file mode 100644 index 0000000..b9da5b9 --- /dev/null +++ b/internal/server/plugin_secret_isolation_test.go @@ -0,0 +1,55 @@ +package server + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/LatticeNet/lattice-server/internal/rbac" +) + +// A plugin secret is readable by the plugin BACKEND and by nothing else. +// +// The plugin KV store shows how easily that is lost: it is reachable over GET /api/kv +// by any principal holding the kv:read RBAC scope, so `plugin:`'s whole bucket is +// browser-readable. Plugin capabilities and operator RBAC scopes are separate +// namespaces that happen to share spellings, and the moment "secret:read" appears in +// KnownScopes it becomes grantable to a token and, from there, reachable from a page. +// +// There is no HTTP handler for the secret collection, and there must never be one. +func TestSecretCapabilitiesAreNotOperatorRBACScopes(t *testing.T) { + for _, capability := range []string{"secret:read", "secret:write"} { + if _, ok := rbac.KnownScopes[capability]; ok { + t.Fatalf("%q is an operator RBAC scope: a token could be granted it, making the "+ + "plugin vault reachable from the browser", capability) + } + } +} + +// Defense in depth, in the crudest and most durable form: no source file outside the +// host adapter may reach the secret store. A future handler added by reflex would trip +// this before it could ship. +func TestNoHTTPHandlerReachesThePluginSecretStore(t *testing.T) { + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || + strings.HasSuffix(name, "_test.go") || name == "plugin_host.go" { + continue + } + body, err := os.ReadFile(filepath.Join(".", name)) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"PluginSecret(", "PutPluginSecret(", "DeletePluginSecret("} { + if strings.Contains(string(body), forbidden) { + t.Errorf("%s reaches the plugin secret store via %s; only the broker's host "+ + "adapter (plugin_host.go) may touch it", name, forbidden) + } + } + } +} diff --git a/internal/store/bolt_state.go b/internal/store/bolt_state.go index a4102ff..816f2d3 100644 --- a/internal/store/bolt_state.go +++ b/internal/store/bolt_state.go @@ -30,6 +30,7 @@ var ( boltBucketResults = []byte("results") boltBucketAudit = []byte("audit") boltBucketKV = []byte("kv") + boltBucketPluginSecrets = []byte("plugin_secrets") boltBucketStatic = []byte("static") boltBucketStorageBuckets = []byte("storage_buckets") boltBucketStorageBindings = []byte("storage_bindings") @@ -72,6 +73,7 @@ var boltStateBuckets = [][]byte{ boltBucketResults, boltBucketAudit, boltBucketKV, + boltBucketPluginSecrets, boltBucketStatic, boltBucketStorageBuckets, boltBucketStorageBindings, @@ -206,6 +208,9 @@ func (bs *BoltStateStore) ImportState(st State) error { if err := putSlice(tx, boltBucketAudit, persist.Audit); err != nil { return err } + if err := putMap(tx, boltBucketPluginSecrets, persist.PluginSecrets); err != nil { + return err + } if err := putMap(tx, boltBucketKV, persist.KV); err != nil { return err } @@ -351,6 +356,9 @@ func (bs *BoltStateStore) ExportState() (State, error) { if err := readSlice(tx, boltBucketAudit, &st.Audit); err != nil { return err } + if err := readMap(tx, boltBucketPluginSecrets, st.PluginSecrets); err != nil { + return err + } if err := readMap(tx, boltBucketKV, st.KV); err != nil { return err } diff --git a/internal/store/crypto.go b/internal/store/crypto.go index 7165ac9..30c4330 100644 --- a/internal/store/crypto.go +++ b/internal/store/crypto.go @@ -96,6 +96,16 @@ func encryptedState(st State, c secret.Cipher) (State, error) { } out.DDNS = ddns + pluginSecrets := make(map[string]model.KVEntry, len(st.PluginSecrets)) + for id, e := range st.PluginSecrets { + enc, err := encryptPluginSecretRecord(id, e, c) + if err != nil { + return State{}, err + } + pluginSecrets[id] = enc + } + out.PluginSecrets = pluginSecrets + dnsDeployments := make(map[string]model.DNSDeployment, len(st.DNSDeployments)) for id, d := range st.DNSDeployments { enc, err := encryptDNSDeploymentRecord(id, d, c) @@ -244,6 +254,16 @@ func decryptState(st *State, c secret.Cipher) error { } st.DDNS = ddns + pluginSecrets := make(map[string]model.KVEntry, len(st.PluginSecrets)) + for id, e := range st.PluginSecrets { + dec, err := decryptPluginSecretRecord(id, e, c) + if err != nil { + return err + } + pluginSecrets[id] = dec + } + st.PluginSecrets = pluginSecrets + dnsDeployments := make(map[string]model.DNSDeployment, len(st.DNSDeployments)) for id, d := range st.DNSDeployments { dec, err := decryptDNSDeploymentRecord(id, d, c) @@ -346,6 +366,11 @@ func stateHasEnvelope(st *State) bool { return true } } + for _, e := range st.PluginSecrets { + if secret.IsEnvelope(e.Value) { + return true + } + } for _, d := range st.DDNS { if secret.IsEnvelope(d.CFAPIToken) || secret.IsEnvelope(d.WebhookHeaders) { return true @@ -498,6 +523,34 @@ func decryptTOTPChallengeRecord(id string, challenge auth.TOTPChallenge, c secre return challenge, nil } +// encryptPluginSecretRecord is the per-record crypto boundary for the plugin vault +// (spec §9.4). Only the value is sealed: the bucket and key name the secret, they are +// not the secret, and keeping them clear is what lets the store look one up without +// decrypting the whole collection. +func encryptPluginSecretRecord(id string, e model.KVEntry, c secret.Cipher) (model.KVEntry, error) { + value, err := c.Encrypt(e.Value) + if err != nil { + return model.KVEntry{}, fmt.Errorf("encrypt plugin secret %s: %w", id, err) + } + e.Value = value + return e, nil +} + +func decryptPluginSecretRecord(id string, e model.KVEntry, c secret.Cipher) (model.KVEntry, error) { + // Authoritative fail-closed point for the bbolt path, which calls these helpers + // directly rather than through decryptState. Without it a lost master key would + // quietly hand the envelope back to a plugin as if it were the secret. + if !c.Enabled() && secret.IsEnvelope(e.Value) { + return model.KVEntry{}, lostMasterKeyError() + } + value, err := c.Decrypt(e.Value) + if err != nil { + return model.KVEntry{}, fmt.Errorf("decrypt plugin secret %s: %w", id, err) + } + e.Value = value + return e, nil +} + func encryptDDNSRecord(id string, d model.DDNSProfile, c secret.Cipher) (model.DDNSProfile, error) { tok, err := c.Encrypt(d.CFAPIToken) if err != nil { diff --git a/internal/store/plugin_secret_test.go b/internal/store/plugin_secret_test.go new file mode 100644 index 0000000..9b98e4b --- /dev/null +++ b/internal/store/plugin_secret_test.go @@ -0,0 +1,158 @@ +package store + +import ( + "crypto/rand" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/LatticeNet/lattice-sdk/model" + "github.com/LatticeNet/lattice-server/internal/secret" +) + +func secretStore(t *testing.T) (*Store, string) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "state.json") + key := make([]byte, secret.KeySize) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + cipher, err := secret.NewAESGCM(key) + if err != nil { + t.Fatal(err) + } + st, err := Open(path) + if err != nil { + t.Fatal(err) + } + st.cipher = cipher + return st, path +} + +// The whole point of a secret store is that the secret is not on the disk. A new State +// collection is serialized into state.json by default, so forgetting to extend +// encryptedState would silently write every plugin credential in cleartext — and no +// type or existing test would notice. This is that test. +func TestPluginSecretIsEncryptedOnDisk(t *testing.T) { + st, path := secretStore(t) + const plaintext = "wg-private-key-AAAABBBBCCCCDDDD" + + if err := st.PutPluginSecret(model.KVEntry{ + Bucket: "pluginsecret:latticenet.wireguard", Key: "node-a.privkey", Value: plaintext, + }); err != nil { + t.Fatal(err) + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), plaintext) { + t.Fatal("plugin secret was written to state.json in cleartext") + } + + // It must be a real envelope, not merely absent or mangled. + var persisted struct { + PluginSecrets map[string]model.KVEntry `json:"plugin_secrets"` + } + if err := json.Unmarshal(raw, &persisted); err != nil { + t.Fatal(err) + } + entry, ok := persisted.PluginSecrets["pluginsecret:latticenet.wireguard/node-a.privkey"] + if !ok { + t.Fatal("plugin secret missing from persisted state") + } + if !secret.IsEnvelope(entry.Value) { + t.Fatalf("persisted plugin secret is not an encryption envelope: %q", entry.Value) + } + // The key name is not the secret and stays readable, which is what lets the store + // look one up without decrypting the collection. + if entry.Key != "node-a.privkey" { + t.Fatalf("unexpected key: %q", entry.Key) + } + + // In-memory state stays plaintext (the store's invariant), so a read returns the + // value directly. + got, ok := st.PluginSecret("pluginsecret:latticenet.wireguard", "node-a.privkey") + if !ok || got.Value != plaintext { + t.Fatalf("in-memory read did not return the plaintext: %+v", got) + } + + // And the crypto boundary round-trips: what encryptedState seals, decryptState + // opens, back to the exact plaintext. + sealed, err := encryptedState(st.state, st.cipher) + if err != nil { + t.Fatal(err) + } + if err := decryptState(&sealed, st.cipher); err != nil { + t.Fatal(err) + } + back, ok := sealed.PluginSecrets["pluginsecret:latticenet.wireguard/node-a.privkey"] + if !ok || back.Value != plaintext { + t.Fatalf("secret did not survive an encrypt/decrypt round trip: %+v", back) + } +} + +// stateHasEnvelope is the lost-master-key guard. If the secrets collection is not +// covered, losing the key degrades to handing envelope strings back to plugins as if +// they were the secret, instead of refusing to start. +func TestPluginSecretCountsTowardLostMasterKeyGuard(t *testing.T) { + store, _ := secretStore(t) + st := emptyState() + if stateHasEnvelope(&st) { + t.Fatal("empty state must not look encrypted") + } + // A real envelope: IsEnvelope is a structural check, so a hand-written string is + // not good enough to prove the guard sees it. + sealed, err := store.cipher.Encrypt("wg-private-key") + if err != nil { + t.Fatal(err) + } + st.PluginSecrets["pluginsecret:x/y"] = model.KVEntry{Bucket: "pluginsecret:x", Key: "y", Value: sealed} + if !stateHasEnvelope(&st) { + t.Fatal("an encrypted plugin secret must trip the lost-master-key guard") + } +} + +func TestPluginSecretBucketIsBounded(t *testing.T) { + st, _ := secretStore(t) + for i := range MaxPluginSecretsPerBucket { + if err := st.PutPluginSecret(model.KVEntry{ + Bucket: "pluginsecret:p", Key: string(rune('a'+i%26)) + strings.Repeat("x", i/26+1), Value: "v", + }); err != nil { + t.Fatalf("entry %d should fit: %v", i, err) + } + } + err := st.PutPluginSecret(model.KVEntry{Bucket: "pluginsecret:p", Key: "one-too-many", Value: "v"}) + if err == nil { + t.Fatal("a plugin must not be able to grow its vault without bound") + } + // Overwriting an existing key is still allowed at the cap. + if err := st.PutPluginSecret(model.KVEntry{Bucket: "pluginsecret:p", Key: "ax", Value: "v2"}); err != nil { + t.Fatalf("overwrite at the cap must be allowed: %v", err) + } +} + +func TestPurgePluginSecretsRemovesOnlyThatPluginsVault(t *testing.T) { + st, _ := secretStore(t) + for _, e := range []model.KVEntry{ + {Bucket: "pluginsecret:a", Key: "k", Value: "a-secret"}, + {Bucket: "pluginsecret:b", Key: "k", Value: "b-secret"}, + } { + if err := st.PutPluginSecret(e); err != nil { + t.Fatal(err) + } + } + if err := st.PurgePluginSecrets("pluginsecret:a"); err != nil { + t.Fatal(err) + } + if _, ok := st.PluginSecret("pluginsecret:a", "k"); ok { + t.Fatal("purged vault still readable") + } + if got, ok := st.PluginSecret("pluginsecret:b", "k"); !ok || got.Value != "b-secret" { + t.Fatal("purging one plugin's vault removed another's") + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 1eb03c2..f82fb23 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -40,13 +40,18 @@ const metricsPersistenceInterval = 5 * time.Minute const monitorResultPersistenceInterval = 5 * time.Minute type State struct { - Users map[string]model.User `json:"users"` - Tokens map[string]model.Token `json:"tokens"` - Nodes map[string]model.Node `json:"nodes"` - Tasks map[string]model.Task `json:"tasks"` - Results []model.TaskResult `json:"results"` - Audit []model.AuditEvent `json:"audit"` - KV map[string]model.KVEntry `json:"kv"` + Users map[string]model.User `json:"users"` + Tokens map[string]model.Token `json:"tokens"` + Nodes map[string]model.Node `json:"nodes"` + Tasks map[string]model.Task `json:"tasks"` + Results []model.TaskResult `json:"results"` + Audit []model.AuditEvent `json:"audit"` + KV map[string]model.KVEntry `json:"kv"` + // PluginSecrets is the encrypted, namespaced plugin vault (spec §9.4). It is a + // distinct collection from KV on purpose: KV is plaintext at rest AND readable + // over GET /api/kv by any principal holding kv:read. A secret must have neither + // property, so it gets its own map, its own cipher pass, and no HTTP handler. + PluginSecrets map[string]model.KVEntry `json:"plugin_secrets"` Static map[string]model.StaticObject `json:"static"` StorageBuckets map[string]model.StorageBucket `json:"storage_buckets"` StorageBindings map[string]model.StorageBinding `json:"storage_bindings"` @@ -337,6 +342,7 @@ func emptyState() State { Nodes: map[string]model.Node{}, Tasks: map[string]model.Task{}, KV: map[string]model.KVEntry{}, + PluginSecrets: map[string]model.KVEntry{}, Static: map[string]model.StaticObject{}, StorageBuckets: map[string]model.StorageBucket{}, StorageBindings: map[string]model.StorageBinding{}, @@ -394,6 +400,9 @@ func (st *State) ensureMaps() { if st.KV == nil { st.KV = map[string]model.KVEntry{} } + if st.PluginSecrets == nil { + st.PluginSecrets = map[string]model.KVEntry{} + } if st.Static == nil { st.Static = map[string]model.StaticObject{} } @@ -1360,6 +1369,62 @@ func (s *Store) KVEntry(bucket, key string) (model.KVEntry, bool) { return e, ok } +// MaxPluginSecretsPerBucket bounds one plugin's vault. KV is unbounded, which is +// tolerable for plaintext scratch data; it is not tolerable here, because every write +// re-encrypts and rewrites the entire state file, so an unbounded vault is both a disk +// amplifier and a way for one plugin to bloat every other plugin's persistence path. +const MaxPluginSecretsPerBucket = 256 + +// PutPluginSecret stores an encrypted-at-rest secret. There is deliberately no +// PluginSecrets(bucket) listing counterpart: a plugin reads back a key it chose to +// write, and nothing — not a plugin, not an HTTP handler — can enumerate the vault. +func (s *Store) PutPluginSecret(entry model.KVEntry) error { + s.mu.Lock() + defer s.mu.Unlock() + id := entry.Bucket + "/" + entry.Key + if _, exists := s.state.PluginSecrets[id]; !exists { + count := 0 + for _, e := range s.state.PluginSecrets { + if e.Bucket == entry.Bucket { + count++ + } + } + if count >= MaxPluginSecretsPerBucket { + return fmt.Errorf("plugin secret bucket holds the maximum of %d entries", MaxPluginSecretsPerBucket) + } + } + entry.UpdatedAt = time.Now().UTC() + s.state.PluginSecrets[id] = entry + return s.Save() +} + +func (s *Store) PluginSecret(bucket, key string) (model.KVEntry, bool) { + s.mu.Lock() + defer s.mu.Unlock() + e, ok := s.state.PluginSecrets[bucket+"/"+key] + return e, ok +} + +func (s *Store) DeletePluginSecret(bucket, key string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.state.PluginSecrets, bucket+"/"+key) + return s.Save() +} + +// PurgePluginSecrets removes an entire plugin's vault. Spec §10 makes purging plugin +// data an explicit, audited operator action; this is the primitive it needs. +func (s *Store) PurgePluginSecrets(bucket string) error { + s.mu.Lock() + defer s.mu.Unlock() + for id, e := range s.state.PluginSecrets { + if e.Bucket == bucket { + delete(s.state.PluginSecrets, id) + } + } + return s.Save() +} + func (s *Store) PutStatic(obj model.StaticObject) error { s.mu.Lock() defer s.mu.Unlock()