Decide whether the OAuth signing key should be sealed under the vault DEK #166

Open
opened 2026-09-16 08:46:04 +02:00 by lz · 0 comments
Owner

Spun out of #151, where this came up in review and was deliberately left as a decision rather than a silent default.

What ships today

nexus/src/lib/server/oauth/jwks.ts generates an EC P-256 signing key once and persists it in the meta table as plaintext JSON. It is not sealed under the vault DEK, unlike connections.encrypted_token and repo_env_vars.encrypted_value.

It is in meta rather than instance_settings for a reason that is not in question: listInstance() returns every instance-scoped setting, so a setting-shaped key would be served by GET /api/settings/instance and rendered in the Settings panel. meta is enumerated by no route.

The exposure

Whoever can read that row can forge arbitrary access, refresh and ID tokens, for any scope and any account, indefinitely. That is strictly worse than lifting individual already-scoped tokens out of oauth_payloads — which are also plaintext, and which #151's spec now documents as such rather than claiming otherwise.

So the honest current threat model is: raw database access is game over for OAuth, and the mitigation is filesystem and backup hygiene, not storage-layer hashing.

Why it was not simply sealed

Sealing it under the DEK ties OAuth token issuance and refresh to the vault being unlocked. That breaks a property the MCP design treats as deliberate: the OAuth endpoints sit outside the cookie gate on purpose (src/server.ts's dispatcher never consults locals.masterKey for provider paths), so a background MCP client keeps working across a Nexus restart without the operator reopening a browser and typing a passphrase.

That is a real trade-off, not an oversight:

  • Seal it — a stolen database file no longer yields a token-forging key, but every Nexus restart silently breaks every connected MCP client until the operator unlocks. On a headless box that could be a long time, and the failure would look like "Claude stopped working" with no obvious cause.
  • Leave it plaintext — the current behaviour. MCP survives restarts; the database file is a single point of total compromise.
  • Something in between — e.g. seal it and keep the unwrapped key in memory once unlocked, accepting that OAuth is unavailable between a restart and the next unlock; or a separate key-encryption secret supplied by the environment rather than the operator's passphrase, which survives restarts without living in the database.

Done when

A decision is made and written down — in the spec, with the reasoning, whichever way it goes. If the answer is "leave it", that is a legitimate outcome and the value of this issue is that the trade-off was chosen rather than defaulted into.

Worth deciding before Nexus instances are exposed with real MCP clients attached, because changing it later invalidates every issued token.

Spun out of #151, where this came up in review and was deliberately left as a decision rather than a silent default. ## What ships today `nexus/src/lib/server/oauth/jwks.ts` generates an EC P-256 signing key once and persists it in the `meta` table as plaintext JSON. It is **not** sealed under the vault DEK, unlike `connections.encrypted_token` and `repo_env_vars.encrypted_value`. It is in `meta` rather than `instance_settings` for a reason that is not in question: `listInstance()` returns every instance-scoped setting, so a setting-shaped key would be served by `GET /api/settings/instance` and rendered in the Settings panel. `meta` is enumerated by no route. ## The exposure Whoever can read that row can forge arbitrary access, refresh and ID tokens, for any scope and any account, indefinitely. That is strictly worse than lifting individual already-scoped tokens out of `oauth_payloads` — which are also plaintext, and which #151's spec now documents as such rather than claiming otherwise. So the honest current threat model is: **raw database access is game over for OAuth**, and the mitigation is filesystem and backup hygiene, not storage-layer hashing. ## Why it was not simply sealed Sealing it under the DEK ties OAuth token issuance and refresh to the vault being **unlocked**. That breaks a property the MCP design treats as deliberate: the OAuth endpoints sit outside the cookie gate on purpose (`src/server.ts`'s dispatcher never consults `locals.masterKey` for provider paths), so a background MCP client keeps working across a Nexus restart without the operator reopening a browser and typing a passphrase. That is a real trade-off, not an oversight: - **Seal it** — a stolen database file no longer yields a token-forging key, but every Nexus restart silently breaks every connected MCP client until the operator unlocks. On a headless box that could be a long time, and the failure would look like "Claude stopped working" with no obvious cause. - **Leave it plaintext** — the current behaviour. MCP survives restarts; the database file is a single point of total compromise. - **Something in between** — e.g. seal it and keep the unwrapped key in memory once unlocked, accepting that OAuth is unavailable between a restart and the next unlock; or a separate key-encryption secret supplied by the environment rather than the operator's passphrase, which survives restarts without living in the database. ## Done when A decision is made and written down — in the spec, with the reasoning, whichever way it goes. If the answer is "leave it", that is a legitimate outcome and the value of this issue is that the trade-off was chosen rather than defaulted into. Worth deciding before Nexus instances are exposed with real MCP clients attached, because changing it later invalidates every issued token.
lz added this to the MCP support (#140) milestone 2026-09-16 08:46:05 +02:00
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus#166
No description provided.