Skip to content
Closed
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
105 changes: 104 additions & 1 deletion internal/plugin/broker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:<pluginID>"), 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
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions internal/plugin/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
197 changes: 197 additions & 0 deletions internal/plugin/secret_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading