Skip to content

feat: give plugins an encrypted secret store (§9.4) - #11

Closed
lr00rl wants to merge 1 commit into
feat/require-declared-backingfrom
feat/plugin-secret-storage
Closed

feat: give plugins an encrypted secret store (§9.4)#11
lr00rl wants to merge 1 commit into
feat/require-declared-backingfrom
feat/plugin-secret-storage

Conversation

@lr00rl

@lr00rl lr00rl commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Stacked on #10#8#7. This PR's diff is its own commit.

Implements Bundle V2 §9.4. This is one of the two prerequisites the spec names before any domain engine can leave core — WireGuard cannot own its key management until a plugin has somewhere safe to put a private key.

The problem

A plugin that manages WireGuard keys or provisions credentials has nowhere safe to store them. The only namespaced storage it can reach is KV, which is:

  1. plaintext at rest, and
  2. readable over GET /api/kv by any principal holding the kv:read RBAC scope.

So a private key written to KV is a private key in cleartext, in a place a browser can fetch.

What this adds

secret:read / secret:write — new host-risk capabilities over a separate encrypted collection.

Host-risk because the value stored 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 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. (Counterintuitive enough that it's commented at the definition.)

Three things that were easy to get wrong

1. Encryption is a property of three functions, not of the store type. A new State map is serialized into state.json by default. Add one and stop there, and every plugin credential lands on disk in the clear — and neither the type system nor any existing test would notice. The collection is therefore wired through encryptedState, decryptState, and stateHasEnvelope. The third matters: without it, a lost master key degrades to handing envelope strings back to plugins as if they were secrets, instead of refusing to start. TestPluginSecretIsEncryptedOnDisk reads state.json back and asserts the plaintext is absent and the stored value is a structurally valid envelope.

2. It is a separate collection, not a corner of KV. Sharing State.KV would have inherited both disqualifying properties above. Distinct bucket prefixes (plugin: vs pluginsecret:) also mean a plugin cannot confuse the two and silently write a key into cleartext storage.

3. Leak channels are closed at each end. secret.get returns base64 onlykv.get also returns a raw string, and a raw secret field is what ends up %v-logged or folded into an error. Errors carry key names and never values, because broker error text becomes the audit Reason. And the capabilities are not operator RBAC scopes, with no HTTP handler — pinned by two tests, one of which fails if any file outside plugin_host.go so much as references the store.

Bounded where KV is not

64 KiB per secret, 256 secrets 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 path.

Known property, stated rather than discovered later

The existing envelope format seals with no AAD (internal/secret/secret.go), so envelopes are portable between records: an attacker with write access to state.json can relocate a ciphertext and it still authenticates. This is pre-existing and applies to every encrypted field today, not just secrets. Binding a secret to its owning plugin ID would require AAD, i.e. an envelope v2 — out of scope here, but worth a decision.

Test plan

  • go vet ./internal/... clean; all internal packages green
  • TestPluginSecretIsEncryptedOnDisk — no plaintext in state.json; value is a real envelope; round-trips
  • TestPluginSecretCountsTowardLostMasterKeyGuard
  • TestSecretKeyIsPinnedToThePluginsOwnVault../, absolute bucket, a/b, a\b, empty all refused
  • TestSecretCapabilitiesAreSystemPluginsOnly — wasm/worker refused
  • TestSecretHoldingV2ManifestIsSignatureRequiredEvenWithUnsignedHostRiskAllowed — the dev escape hatch cannot reach a v2 secret-holding plugin
  • TestSecretCapabilitiesAreNotOperatorRBACScopes + TestNoHTTPHandlerReachesThePluginSecretStore

Still unbuilt: §9.3 execute (plan → approval → one-time operation capability → bounded task enqueue). Nothing should ship a plugin execute path until that lands.

https://claude.ai/code/session_01FzYExyy6G7Cb7QpXoUEY78

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.
@lr00rl

lr00rl commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

[ack] with one recorded caveat — zeus review, Olympus TASK-0001 item 3 (2026-07-26)

§9.4 as ruled: separate encrypted collection (distinct pluginsecret: prefix), wired through encryptedState + decryptState + stateHasEnvelope plus a fourth fail-closed point on the bbolt direct path (lost master key → refuse, never hand envelopes back as secrets). secret:* capabilities absent from rbac.KnownScopes and no HTTP handler — both pinned by tests; the source-grep pin is crude and durable, exactly right. Bounds (64KiB/256) prevent the state-file amplifier.

Caveat on record (pre-existing, repo-wide, not this PR's defect): envelopes seal with no AAD, so ciphertexts are relocatable between records by a state.json-writing attacker. Envelope v2 with AAD binding needs an operator decision — follow-up task drafted in Olympus.

Evidence: contained in integration (86422a1); full -race -cover green (internal/secret 80.7%, internal/store 60.4%). Disposition: close-with-landing-commit per rules/01 §8.5.

@lr00rl

lr00rl commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Closed as landed — zeus, operator-approved sweep (2026-07-26, rules/01 §8.5): commit 75379e2 contained in integration (86422a1). [ack] verdict above; AAD caveat tracked as Olympus TASK-0009 (awaiting operator decision). Closing, not rejecting.

@lr00rl lr00rl closed this Jul 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant