feat: give plugins an encrypted secret store (§9.4) - #11
Conversation
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.
|
[ack] with one recorded caveat — zeus review, Olympus TASK-0001 item 3 (2026-07-26) §9.4 as ruled: separate encrypted collection (distinct 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 |
|
Closed as landed — zeus, operator-approved sweep (2026-07-26, rules/01 §8.5): 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:
GET /api/kvby any principal holding thekv:readRBAC 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:readon a compromised plugin reads back everything it ever wrote. They are deliberately absent fromhostRiskExemptForNonSystemandworkerCapabilities: 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
Statemap is serialized intostate.jsonby 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 throughencryptedState,decryptState, andstateHasEnvelope. 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.TestPluginSecretIsEncryptedOnDiskreadsstate.jsonback 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.KVwould have inherited both disqualifying properties above. Distinct bucket prefixes (plugin:vspluginsecret:) 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.getreturns base64 only —kv.getalso 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 auditReason. And the capabilities are not operator RBAC scopes, with no HTTP handler — pinned by two tests, one of which fails if any file outsideplugin_host.goso 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 tostate.jsoncan 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 greenTestPluginSecretIsEncryptedOnDisk— no plaintext instate.json; value is a real envelope; round-tripsTestPluginSecretCountsTowardLostMasterKeyGuardTestSecretKeyIsPinnedToThePluginsOwnVault—../, absolute bucket,a/b,a\b, empty all refusedTestSecretCapabilitiesAreSystemPluginsOnly— wasm/worker refusedTestSecretHoldingV2ManifestIsSignatureRequiredEvenWithUnsignedHostRiskAllowed— the dev escape hatch cannot reach a v2 secret-holding pluginTestSecretCapabilitiesAreNotOperatorRBACScopes+TestNoHTTPHandlerReachesThePluginSecretStoreStill unbuilt: §9.3 execute (plan → approval → one-time operation capability → bounded task enqueue). Nothing should ship a plugin
executepath until that lands.https://claude.ai/code/session_01FzYExyy6G7Cb7QpXoUEY78