inspector/core/auth/secret-storage-info.ts
cliffhall 3452a63101 fix(auth): address Copilot review on the secret-store PR
Six findings, all taken.

- The descriptor reported the *write policy*, not the file. Adding
  `MCP_INSPECTOR_SECRET_KEY` to an install that already has a plaintext
  file flipped `store.encrypted` immediately, so the banner and footer
  claimed "File (encrypted)" while the existing secrets sat readable
  until the next `set`. `FileSecretStore.readOnDiskEncryption()` now
  reads the envelope (never the payload, so it needs no passphrase) and
  the descriptor is built from that. The transitional state gets its own
  `pendingEncryption` flag, which changes the advice without softening
  the verdict — "set a passphrase" is the wrong thing to tell someone who
  already has, so the caveat names the pending write instead.

- The secrets file ignored the configured storage directory. A container
  mounting only that directory was judged unmounted, fell back to
  `memory`, and lost secrets the user had arranged to keep.
  `defaultSecretFilePath()` now follows `MCP_STORAGE_DIR` — the variable
  that already relocates OAuth tokens and `client.json` — between the
  explicit `MCP_INSPECTOR_SECRET_FILE` and the `~/.mcp-inspector`
  default. (The issue comment called it `MCP_INSPECTOR_STORAGE_DIR`; no
  such variable exists.)

- `version` was never checked before the plaintext branch, so a
  `{ version: 2, encryption: "none" }` file was read and silently
  rewritten as version 1, discarding whatever the newer format added —
  the same destroy-to-satisfy-an-additive-request case the encrypted
  branch already refuses. Checked first now, so the answer no longer
  depends on which encryption mode the newer writer chose.

- The doc comment claimed a per-file salt made "a short, memorable value
  safe to use". It does not: the salt defeats precomputation, not
  guessing, and the scrypt cost is deliberately a few milliseconds
  because it runs on every read and write. Both the comment and the
  README now say to use a high-entropy value, generated rather than
  chosen.

- `usableSecretStorage` validated only `kind`, so a partial
  `{ kind: "file" }` was cast through and — because a missing `plaintext`
  is falsy — rendered as the quiet, neutral *encrypted* file. That is the
  confident half-answer the guard exists to reject, produced by the
  absence of information rather than by any claim the backend made.
  `reason`, `durable`, and each kind's own required fields are checked.

- The footer's inline `Tooltip` carried four static props; extracted to a
  `CaveatTooltip` constant per the `.withProps()` rule.

Tests cover each: the policy/file disagreement in both directions and the
no-file-yet fallback, `MCP_STORAGE_DIR` and its precedence against
`MCP_INSPECTOR_SECRET_FILE`, the version refusal (asserting the bytes
survive it) split from the unrecognized-cipher refusal it used to be
conflated with, `readOnDiskEncryption` including the keyless and
unparseable cases, five new rejected descriptor shapes, and the changed
caveat. Full `npm run ci` green.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 12:26:33 -04:00

140 lines
5.7 KiB
TypeScript

/**
* The description of *where a typed secret ends up* — the one fact this
* whole subsystem exists to make unmissable (#1950).
*
* Pure data plus its presentation helpers, deliberately kept out of
* `core/auth/node/` so the browser can import it. The Node side resolves
* the active store (`core/auth/node/secret-store-selection.ts`) and
* produces one of these; it then travels three ways, and all three read
* the same object so they cannot disagree:
*
* 1. the startup banner the launcher prints,
* 2. `GET /api/config` → the web UI's settings-modal footers,
* 3. the `SecretStore` doc comment's contract, in prose.
*
* A store's *kind* answers "where", `plaintext` answers "readable by
* anyone who can read the file", and `durable` answers "will it still be
* there next run". Those are three separate questions and a user asking
* one of them is rarely helped by the answer to another, so none is
* folded into the others.
*/
/** Which backend is holding secrets this session. */
export type SecretStoreKind = "keyring" | "file" | "memory";
/**
* Why the active store is the active store. `configured` means the user
* named it via `MCP_INSPECTOR_SECRET_STORE`; `default` means the keychain
* was reachable and we used it; `fallback` means the keychain was *not*
* reachable and we picked something that works instead — the case that
* earns a loud banner, because it is the one where the answer to "where
* did my secret go" changed without the user asking for it.
*/
export type SecretStoreReason = "configured" | "default" | "fallback";
export interface SecretStorageInfo {
kind: SecretStoreKind;
reason: SecretStoreReason;
/**
* Absolute path of the secrets file, for `kind: "file"` only. Shown in
* the UI and the banner: a file-backed store whose location is a
* mystery is barely better than no answer at all.
*/
path?: string;
/**
* True when the secrets file is *currently* in the clear. Read off the
* file's own envelope rather than off whether a passphrase is
* configured, because those two disagree for a whole session: adding
* `MCP_INSPECTOR_SECRET_KEY` to an install that already has a plaintext
* file makes the next write encrypt, while the existing bytes stay
* readable until then. Reporting the intent would tell that user their
* secrets were encrypted while they were not.
*
* Always false for the other kinds — a keychain is opaque to us and RAM
* is not "at rest" in the sense this flag is about.
*/
plaintext?: boolean;
/**
* True in exactly the transitional state above: `plaintext` is still
* true, but a passphrase is set and the next write will encrypt. It
* changes the *advice* rather than the verdict — "set a passphrase" is
* the wrong thing to tell someone who already has, so the caveat says
* what will actually clear the condition.
*/
pendingEncryption?: boolean;
/**
* True when secrets outlive the process. False only for `memory`, and
* it is the *promise* being made rather than an implementation detail:
* an in-memory store is honest precisely because it never claims
* durability it cannot deliver in a container's writable layer.
*/
durable: boolean;
/**
* Why the keychain was unreachable, when `reason === "fallback"`. Free
* text from the underlying loader/binding — surfaced rather than
* summarized, because the three realistic causes (missing libsecret, no
* platform binary, a packaging mismatch) need different fixes and only
* the raw message distinguishes them.
*/
detail?: string;
}
/** Short label naming the store — the footer's primary text. */
export function secretStorageLabel(info: SecretStorageInfo): string {
switch (info.kind) {
case "keyring":
return "OS keychain";
case "file":
return info.plaintext ? "File (unencrypted)" : "File (encrypted)";
case "memory":
return "Memory (this session only)";
}
}
/**
* How loudly to say it. `warn` is reserved for the two states a user
* would want to know about before typing a secret — it is on disk in the
* clear, or it is about to be lost on exit — so that the ordinary
* keychain and encrypted-file cases stay quiet and the warning keeps its
* meaning.
*/
export type SecretStorageTone = "neutral" | "warn";
export function secretStorageTone(info: SecretStorageInfo): SecretStorageTone {
if (info.kind === "memory") return "warn";
if (info.plaintext) return "warn";
return "neutral";
}
/**
* One sentence of consequence, or undefined when there is none worth
* saying. Consequence, not mechanism: "will be lost when the Inspector
* exits" is what a user can act on; "uses an in-memory Map" is not.
*/
export function secretStorageCaveat(
info: SecretStorageInfo,
): string | undefined {
if (info.kind === "memory") {
return "Secrets are not written anywhere and are lost when the Inspector exits.";
}
if (info.plaintext) {
return info.pendingEncryption
? "Existing secrets in this file are still unencrypted (file mode 0600). They are re-encrypted the next time a secret is saved."
: "Secrets are stored unencrypted (file mode 0600). Set MCP_INSPECTOR_SECRET_KEY to encrypt them.";
}
return undefined;
}
/**
* The full sentence, for the startup banner and any single-line surface.
* Built from the same three helpers the UI uses so the terminal and the
* browser cannot drift into describing the same store differently.
*/
export function secretStorageSummary(info: SecretStorageInfo): string {
const parts = [secretStorageLabel(info)];
if (info.path) parts.push(`at ${info.path}`);
if (info.reason === "fallback") {
parts.push("— fell back from the OS keychain");
}
return parts.join(" ");
}