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()