inspector/core/auth/node/secret-store.ts
cliffhall fdeffa1f00 fix(auth): address Copilot review round 6 on the secret-store PR
Four findings, all suppressed, all real. Three are the same mistake in
different places: a state that was never established being reported as a
confident answer.

- **`readOnDiskEncryption` conflated "no file" with "unreadable file".**
  Both answered `null`, and the descriptor falls back to the configured
  write policy on `null` — so a passphrase-configured install reported
  "File (encrypted)" about a file whose envelope had never parsed, and
  which `set` was in fact about to refuse rather than overwrite. It now
  returns a four-state result, and only `absent` falls back to the policy
  (honestly: the first write really will create it that way). `unreadable`
  produces `encryptionUnknown` on the descriptor, `plaintext` is omitted
  entirely so nothing can read a default out of it, and the label, caveat,
  tone and footer all carry "unreadable" rather than guessing a mode.

- **The loose-mode warning overstated exposure for an encrypted file.**
  "Anyone who can read it can read the secrets in it" is false when the
  file is ciphertext — a reader gets the bytes and still needs the
  passphrase. Both the caveat and the footer tooltip now branch on
  encryption: the passphrase becomes the only protection, which is the
  real consequence. Overstating it is not harmless, because a warning that
  cries wolf is discounted on the occasion it is literally true.

- **The keychain hand-off could invert its own keychain-wins rule.**
  `SecretStore.get` answers `null` both for a missing entry and for a
  keychain it could not read, and the migration *writes* on `null` — so a
  transient read failure would overwrite a newer keychain value with the
  older file copy. Added an optional `getStrict` (throws instead of
  swallowing) implemented by `KeyringSecretStore`, with
  `secretStoreGetStrict` falling back to `get` for stores whose reads
  cannot fail. A throw now aborts the hand-off with the file left in
  place, so the next run retries.

Writing the tests turned up one more, in code the review had passed:
`parseOwner` recorded a garbled pid as `-1`, and `process.kill(-1, 0)` is
a *wildcard* asking about every process this user can signal — it answers
"alive" almost always, which would have made a lock with a corrupt stamp
permanently unstealable. Only a positive pid is usable now; anything else
is treated as no pid at all and the mtime decides.

Tests: the four envelope states kept distinct; the descriptor refusing to
guess, with the genuine no-file case as the control; strict-read abort
without a write and with the file preserved, plus a store whose `get` and
`getStrict` disagree so the test proves which one the migration consults;
encryption-aware wording asserted in both directions in the caveat and the
band; three unusable owner stamps and a wildcard pid; and the descriptor
shapes that must now be rejected — neither half of the encryption question
answered, or both. Full `npm run ci` green — 5,750 tests under the
coverage gate.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 14:40:29 -04:00

567 lines
24 KiB
TypeScript

/**
* Per-server secret storage backed by the OS keychain.
*
* Service name `mcp-inspector`; account `${serverId}:${field}`. Fields used
* by the current code: `oauth-client-secret` and `env:${KEY}` (one per
* stdio env variable). Keeping the account namespaced by `serverId` lets
* us drop every entry for a server in one sweep when DELETE
* /api/servers/:id runs, and lets `findCredentials(SERVICE)` enumerate
* everything we own for migration / debugging.
*
* Node-only — `@napi-rs/keyring` uses native bindings (Keychain Services
* on macOS, Credential Manager on Windows, libsecret on Linux). The
* browser side never imports this; it gets values rehydrated into the
* `/api/servers` response by the Hono handler.
*/
const SERVICE_NAME = "mcp-inspector";
/**
* `@napi-rs/keyring` ships one prebuilt binary per platform triple, and
* loading the package *throws* on a platform it has no binary for —
* Android / Termux is the reported case (#1905), where the import fails
* with "Cannot find native binding" / "Cannot find module
* '@napi-rs/keyring-android-arm64'".
*
* A static top-level import made that a startup crash: the module never
* evaluated, so the Inspector exited before any of the
* keychain-unavailable handling below could run. Loading it lazily, and
* caching the *outcome* rather than only the module, folds an
* unsupported platform into the same degradation contract as an
* unreachable keychain (see `KeyringSecretStore`) — the store is simply
* unavailable, and callers see it through the documented behavior
* instead of a crash.
*
* The failure is cached alongside the success so a box without a binary
* doesn't re-attempt (and re-throw) the resolution on every secret
* operation, and so `set` can name the underlying cause in its
* `KeychainUnavailableError`.
*
* The cache is process-lifetime by design — there is no reset seam, since
* a platform does not grow a native binary mid-run. Tests reach the
* unloadable path through `vi.resetModules()` + `vi.doMock`, which gives
* them a fresh module (and so a fresh cache) instead.
*
* A resolved import is also **shape-checked** before being accepted. The
* package is CJS, so the named exports we rely on come from interop, and
* a resolution that stopped yielding them (a default-only export in a
* future version, a bundler changing interop, a platform where the
* named-export detection fails) would land as `undefined`, making
* `new mod.AsyncEntry(...)` throw `TypeError: not a constructor` from
* inside the very `try` that implements graceful degradation.
*
* Be precise about what this buys, because it is narrower than it looks:
* it does **not** stop a bad shape from emptying the secret list. `get`
* returns `null` either way — that is its read-tolerance contract, and
* the `TypeError` lands in the same `catch` that a dead keychain does.
* What the check changes is *diagnosis*. Without it the only signal is
* `set` reporting "keyring.mod.AsyncEntry is not a constructor", an
* internal-looking message that reads like an Inspector bug; with it,
* `set` names the actual problem once, at the load boundary, in the same
* actionable 503 as every other flavor of unavailability. Detecting the
* silent-empty-list case itself would take a real round-trip against the
* unmocked package, which nothing in the suite does today.
*/
type KeyringModule = typeof import("@napi-rs/keyring");
type KeyringLoad =
| { ok: true; mod: KeyringModule }
| { ok: false; err: unknown };
let keyringLoad: Promise<KeyringLoad> | undefined;
/**
* Accept a resolved import only if it carries the two members we call.
*
* The member *access* is inside the `try` because reading a missing
* export is not always the harmless `undefined` a plain ESM namespace
* gives: a Proxy-based namespace can throw on an unknown key (vitest's
* module mocks do exactly that). Either way the answer is the same —
* unavailable — and returning it rather than throwing is what keeps the
* cached promise from ever rejecting.
*/
/**
* Marks the shape-check failure so `KeychainUnavailableError` can give
* advice that fits it. A distinct type rather than a string match on the
* message: this is our own error, so there is no reason to re-parse text
* we just wrote (the native-binding branch matches on a string only
* because that message comes from someone else's loader).
*/
export class KeyringModuleShapeError extends Error {
constructor(detail: string, options?: { cause?: unknown }) {
super(`@napi-rs/keyring loaded but ${detail}`, options);
this.name = "KeyringModuleShapeError";
}
}
const checkKeyringShape = (mod: KeyringModule): KeyringLoad => {
try {
if (
typeof mod.AsyncEntry === "function" &&
typeof mod.findCredentialsAsync === "function"
) {
return { ok: true, mod };
}
return {
ok: false,
err: new KeyringModuleShapeError(
"did not expose AsyncEntry / findCredentialsAsync",
),
};
} catch (err) {
// Both ways of failing the shape check are the same problem — the
// module is not the API we expect — so both carry the type that
// earns the packaging hint. Returning the raw error here instead
// would drop it back to the libsecret advice, which is what this
// whole branch exists to avoid.
return {
ok: false,
err: new KeyringModuleShapeError(
`its exports could not be read: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err },
),
};
}
};
const loadKeyring = (): Promise<KeyringLoad> => {
keyringLoad ??= import("@napi-rs/keyring").then(
checkKeyringShape,
(err: unknown): KeyringLoad => ({ ok: false, err }),
);
return keyringLoad;
};
export {
SECRET_FIELD_OAUTH_CLIENT_SECRET,
SECRET_FIELD_IDP_CLIENT_SECRET,
envSecretField,
} from "../secret-fields.js";
/** Parse a stored account key back into its server id and field. */
export function parseAccount(
account: string,
): { serverId: string; field: string } | null {
const idx = account.indexOf(":");
if (idx <= 0 || idx === account.length - 1) return null;
return {
serverId: account.slice(0, idx),
field: account.slice(idx + 1),
};
}
const buildAccount = (serverId: string, field: string): string =>
`${serverId}:${field}`;
/**
* Base type for "the active secret store cannot do this right now".
*
* Every store implementation hard-fails on `set` and only on `set` (see
* the {@link SecretStore} contract), and the API routes translate that
* one condition into a 503. Before #1950 there was only one store, so the
* routes could match on `KeychainUnavailableError` directly; now that a
* file-backed store can fail for entirely different reasons (an
* unreadable file, a changed passphrase) they match on this base instead,
* and a future store gets the same treatment by extending it rather than
* by every route learning its name.
*
* The message is the user-facing text — these are surfaced verbatim in
* the 503 body — so subclasses write advice, not diagnostics.
*/
export class SecretStoreUnavailableError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message, options);
this.name = "SecretStoreUnavailableError";
}
}
/**
* Thrown when the OS keychain is unavailable. Surfaced as a 503 by the
* API handlers so the UI can show an actionable error rather than a
* generic 500 — and "actionable" is the point: the causes need
* *different* fixes, so the message carries a hint chosen per cause
* (see `hintFor`). Three realistic ones:
*
* - **The keychain itself is missing** — Linux without libsecret /
* gnome-keyring. Install it.
* - **`@napi-rs/keyring` won't load** — no platform binary for this
* triple (Android/Termux, #1905) or npm's optional-deps bug dropping
* it on a supported one (npm/cli#4828). Reinstall / clear the npx
* cache; installing a keyring daemon would not help.
* - **It loads but exposes the wrong API** — a version or packaging
* mismatch (`KeyringModuleShapeError`). Also not a daemon problem.
*/
export class KeychainUnavailableError extends SecretStoreUnavailableError {
constructor(cause: unknown) {
const message = cause instanceof Error ? cause.message : String(cause);
super(
`OS keychain is not available. ${hintFor(cause, message)} Underlying error: ${message}`,
);
this.name = "KeychainUnavailableError";
}
}
/**
* The remediation that fits the cause. Wrong advice is worse than none —
* telling someone on Windows to install libsecret sends them down a path
* that cannot work — so every cause that has its own fix gets its own
* branch, and the libsecret line is the fallback rather than the default.
*/
const hintFor = (cause: unknown, message: string): string => {
// Our own error, so match on the type rather than re-parsing text we wrote.
if (cause instanceof KeyringModuleShapeError) {
return `The @napi-rs/keyring package loaded but does not expose the API this build expects — most likely a version or packaging mismatch; reinstall the Inspector, and report this if it persists.`;
}
// This phrasing comes from the napi-rs loader, not from us: it is what
// the package throws when the platform binary is missing.
if (message.includes("Cannot find native binding")) {
return `The @napi-rs/keyring platform package for this OS is missing or unavailable — reinstall the Inspector (for npx, clear the npx cache under your npm cache directory first).`;
}
return `On Linux, install libsecret / gnome-keyring.`;
};
/**
* Probe whether the OS keychain is actually usable, rather than merely
* importable.
*
* This is the input to the automatic-fallback policy in
* `secret-store-selection.ts`, and it deliberately does more than
* {@link loadKeyring}: on the container that motivated #1848 the package
* imports and shape-checks perfectly well, and the failure only appears
* when `AsyncEntry::new` tries to reach a Secret Service that isn't
* there. So the probe constructs an entry and performs a real read.
*
* A **read** and not a write, on purpose. A write would be the stronger
* signal — a keychain can in principle be readable and not writable — but
* it means depositing a value in the user's login keyring at every
* startup, for a store they may never use, that we would then have to
* clean up (and would leave behind if the process died between the two
* calls). Writing into someone's keychain uninvited to answer a question
* about our own configuration is not a trade worth making; a write that
* fails after a passing probe still surfaces as the documented 503.
*
* Never throws — the whole point is to answer a question, and a probe
* that could fail its caller would just move the crash it exists to
* prevent.
*/
export async function probeKeyringAvailable(): Promise<
{ available: true } | { available: false; detail: string }
> {
const keyring = await loadKeyring();
if (!keyring.ok) {
const err = keyring.err;
return {
available: false,
detail: err instanceof Error ? err.message : String(err),
};
}
try {
const entry = new keyring.mod.AsyncEntry(SERVICE_NAME, PROBE_ACCOUNT);
await entry.getPassword();
return { available: true };
} catch (err) {
return {
available: false,
detail: err instanceof Error ? err.message : String(err),
};
}
}
/**
* Account used by {@link probeKeyringAvailable}. Namespaced under a server
* id that cannot collide with a real one (`:` is the account separator, so
* a real id never contains one) and never written — it only ever appears
* as the argument to a lookup that is expected to miss.
*/
const PROBE_ACCOUNT = "__inspector:probe";
/**
* Storage interface for the per-server secrets we lift off
* `~/.mcp-inspector/mcp.json`.
*
* Three implementations, selected per run by
* `secret-store-selection.ts` (#1950) and described to the user by the
* `SecretStorageInfo` it returns:
*
* - `KeyringSecretStore` — the OS keychain, and the default wherever one
* is reachable.
* - `FileSecretStore` — `~/.mcp-inspector/secrets.json`, `0600`,
* encrypted when `MCP_INSPECTOR_SECRET_KEY` is set. The fallback on a
* host with no keychain, and on a container with a mounted volume.
* - `InMemorySecretStore` — the session-scoped store. Used by the test
* suite (so CI needs no libsecret), and as the container fallback when
* nothing durable is mounted, where it is the honest choice rather than
* the degraded one: a file in the writable layer promises persistence
* the next `docker run` will not honor.
*
* All three share one availability contract, which is what lets callers
* stay ignorant of which they got: `get` is tolerant (`null` on any
* failure), `delete` no-ops, and `set` is the only operation that
* hard-fails — throwing a {@link SecretStoreUnavailableError} the API
* routes turn into a 503 — because it is the only one where a value would
* be lost.
*/
export interface SecretStore {
/**
* Do values written here outlive the process?
*
* Optional, and **absent means durable** — every store that predates
* #1950 is, and a custom one is far more likely to be backed by
* something than by RAM.
*
* It exists because the *migrations* need it. Both of them (mcp.json in
* `server.ts`, client.json in `client/node-persistence.ts`) lift a
* plaintext secret off disk, write it to this store, and then strip it
* from the file — a safe trade only while "written to the store" is at
* least as durable as "left on disk". `InMemorySecretStore` used to be
* a test double only, so that held by construction; making it a
* production fallback broke it, and a plain `GET /api/servers` on an
* unmounted container would have moved a user's existing secrets into
* RAM and lost them at exit.
*/
isDurable?(): Promise<boolean>;
get(serverId: string, field: string): Promise<string | null>;
/**
* Like {@link get}, but **throws** when the store cannot be read instead
* of answering `null`.
*
* `get` is deliberately tolerant — an unreachable keychain and an absent
* entry are the same answer to a caller that just wants to render a form.
* That tolerance is wrong for exactly one caller: a migration that treats
* `null` as proof of absence and then *writes*. A transient read failure
* would look like "nothing there", and the write would overwrite a newer
* keychain value with an older file copy — silently inverting the
* keychain-wins rule the migration exists to honor.
*
* Optional, so existing implementations (and test doubles) keep
* compiling; {@link secretStoreGetStrict} falls back to `get` when it is
* absent, which is correct for stores whose reads cannot fail.
*/
getStrict?(serverId: string, field: string): Promise<string | null>;
set(serverId: string, field: string, value: string): Promise<void>;
/** No-op if no entry exists. */
delete(serverId: string, field: string): Promise<void>;
/** Remove every secret stored for this server id (called on DELETE /api/servers/:id). */
deleteAllForServer(serverId: string): Promise<void>;
}
/**
* Default implementation. Each operation constructs a fresh `AsyncEntry`;
* the native side is cheap and the alternative (caching entries by
* (serverId, field)) just trades native-handle bookkeeping for an
* allocation that's measured in microseconds. `getPassword` returns
* `undefined` for a missing entry — we normalize to `null` so callers
* can use `=== null` rather than truthiness (an empty-string secret is
* a real value and must round-trip).
*
* **Availability behavior.** When the keychain is unavailable, `set` is
* the only operation that throws `KeychainUnavailableError` — that's
* the moment where data would actually be lost. `get` returns `null`
* (as if no entry existed) and the destructive operations silently
* no-op (there's nothing to delete anyway). This keeps non-secret flows
* working on a stock CI runner / minimal Linux box / unsupported
* platform; the user only hits a hard error when they actually try to
* save a secret.
*
* "Unavailable" covers four distinct failures, all funneled into that
* one contract — the contract is only as good as its narrowest funnel,
* and each of these escaped it at some point:
*
* 1. **The package won't load at all** — no prebuilt binary for this
* platform (Android / Termux). A static top-level import made this a
* startup crash before any handling ran (#1905); `loadKeyring()`
* above defers and caches it instead.
* 2. **The package loads but exposes the wrong shape** — the named
* exports arrive via CJS interop, so a resolution that stopped
* yielding them would hand us `undefined` and fail as a `TypeError`
* swallowed by the degradation path. `loadKeyring()` shape-checks up
* front so `set` can name that cause instead of surfacing an
* "is not a constructor" message (see the note there — the check
* improves the diagnosis, it does not change what `get` returns).
* 3. **`AsyncEntry::new` throws** — it performs the platform-store setup
* (on Linux, the Secret Service connect with a keyutils fallback) and
* throws when no backend is reachable. Construction is therefore
* deliberately **inside** each method's `try`; outside it, the raw
* error escaped and 500'd every `GET /api/servers` before any secret
* was involved (#1848).
* 4. **The operation itself throws** — the original case, and the only
* one the first version of this contract actually handled.
*/
export class KeyringSecretStore implements SecretStore {
/**
* The intolerant read. Same lookup as {@link get}, minus the catch — a
* keychain that cannot answer must not be reported as an empty one to a
* caller that is about to write based on the answer.
*/
async getStrict(serverId: string, field: string): Promise<string | null> {
const keyring = await loadKeyring();
if (!keyring.ok) throw new KeychainUnavailableError(keyring.err);
const entry = new keyring.mod.AsyncEntry(
SERVICE_NAME,
buildAccount(serverId, field),
);
return (await entry.getPassword()) ?? null;
}
async get(serverId: string, field: string): Promise<string | null> {
try {
const keyring = await loadKeyring();
if (!keyring.ok) return null;
const entry = new keyring.mod.AsyncEntry(
SERVICE_NAME,
buildAccount(serverId, field),
);
const v = await entry.getPassword();
return v ?? null;
} catch {
// Tolerate keychain unavailability on reads: there's no value to
// surface either way. Hard-failing here would break GET flows
// that don't touch any secret material (most of the test suite,
// and most user sessions on a Linux box without libsecret).
return null;
}
}
async set(serverId: string, field: string, value: string): Promise<void> {
try {
const keyring = await loadKeyring();
// An unloadable package is as fatal to a write as an unreachable
// keychain, and for the same reason — the value would vanish.
if (!keyring.ok) throw new KeychainUnavailableError(keyring.err);
const entry = new keyring.mod.AsyncEntry(
SERVICE_NAME,
buildAccount(serverId, field),
);
await entry.setPassword(value);
} catch (err) {
// Already the typed error when the module failed to load — don't
// double-wrap it (that would bury the underlying cause one level
// deeper in the message).
if (err instanceof KeychainUnavailableError) throw err;
// The only operation that hard-fails — if we can't persist the
// secret, the user needs to know now rather than discover later
// that their value disappeared. Routes translate this to a 503.
throw new KeychainUnavailableError(err);
}
}
async delete(serverId: string, field: string): Promise<void> {
try {
const keyring = await loadKeyring();
if (!keyring.ok) return;
const entry = new keyring.mod.AsyncEntry(
SERVICE_NAME,
buildAccount(serverId, field),
);
await entry.deleteCredential();
} catch {
// Every reason for a throw collapses to the same desired outcome
// ("the entry isn't there anymore"): `deleteCredential` raises
// NoEntry for a missing credential, and both the constructor and
// the native binding raise a runtime error when the keychain
// itself is unavailable. We treat all of them as success — there's
// no value to lose either way, and `set` is the operation that
// hard-fails when the keychain is actually down.
}
}
async deleteAllForServer(serverId: string): Promise<void> {
let creds: Array<{ account: string; password: string }>;
try {
const keyring = await loadKeyring();
if (!keyring.ok) return;
creds = await keyring.mod.findCredentialsAsync(SERVICE_NAME);
} catch {
// Same reasoning as `delete`: nothing was written, nothing to sweep.
return;
}
const prefix = `${serverId}:`;
for (const c of creds) {
if (!c.account.startsWith(prefix)) continue;
const parsed = parseAccount(c.account);
if (!parsed || parsed.serverId !== serverId) continue;
await this.delete(serverId, parsed.field);
}
}
}
/**
* Test double — substituted via the `secretStore` option on the remote
* server factory. Mirrors the keyring contract exactly so swapping it
* in/out doesn't change behavior beyond persistence.
*/
/**
* Is `store` durable? Absent `isDurable` means yes — see the interface.
*
* A free function rather than a required method so the existing test
* doubles (and any third-party implementation) keep compiling, while the
* one store that is *not* durable has to say so explicitly. Defaulting the
* other way would make every double silently non-durable and quietly
* disable the migrations they exist to exercise.
*/
/**
* Read `store` intolerantly: throw rather than answer `null` when the store
* itself cannot be read. Falls back to `get` for a store that does not
* distinguish the two, which is right for the in-memory and file stores —
* neither can fail in a way that masquerades as absence.
*/
export async function secretStoreGetStrict(
store: SecretStore,
serverId: string,
field: string,
): Promise<string | null> {
return store.getStrict
? store.getStrict(serverId, field)
: store.get(serverId, field);
}
export async function secretStoreIsDurable(
store: SecretStore,
): Promise<boolean> {
return store.isDurable ? store.isDurable() : true;
}
export class InMemorySecretStore implements SecretStore {
private readonly map = new Map<string, string>();
async get(serverId: string, field: string): Promise<string | null> {
return this.map.get(buildAccount(serverId, field)) ?? null;
}
async set(serverId: string, field: string, value: string): Promise<void> {
this.map.set(buildAccount(serverId, field), value);
}
async delete(serverId: string, field: string): Promise<void> {
this.map.delete(buildAccount(serverId, field));
}
async deleteAllForServer(serverId: string): Promise<void> {
const prefix = `${serverId}:`;
for (const key of [...this.map.keys()]) {
if (key.startsWith(prefix)) this.map.delete(key);
}
}
}
/**
* The in-memory store as a **production** choice — the fallback for a
* container with no keychain and nothing durable mounted (#1950).
*
* Behaviorally identical to {@link InMemorySecretStore}; it exists to
* answer `isDurable()` with `false`, and that one bit is load-bearing.
* Both migrations (mcp.json, client.json) lift a plaintext secret off
* disk, write it to the store, and then delete it from the file — safe
* only while the store outlives the process. Against a session-scoped
* store that trade destroys the secret, and it runs on an ordinary read,
* so merely opening the app would do it.
*
* Kept as a separate class rather than a flag on the base so the test
* suite's many `new InMemorySecretStore()` doubles keep standing in for a
* *working keychain*, which is what they are there to be. Only the code
* that deliberately chooses RAM in production says so.
*/
export class SessionSecretStore extends InMemorySecretStore {
async isDurable(): Promise<boolean> {
return false;
}
}