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>
This commit is contained in:
cliffhall 2026-08-22 12:26:33 -04:00
commit 3452a63101

View file

@ -32,8 +32,16 @@
*
* **Key.** `MCP_INSPECTOR_SECRET_KEY` is a passphrase, not a key: it is
* stretched with scrypt against a per-file random salt stored beside the
* ciphertext. That means a short, memorable value is safe to use, and it
* means the same passphrase produces a different key for a different file.
* ciphertext, so the same passphrase produces a different key for a
* different file and a precomputed table buys an attacker nothing.
*
* That is **not** a licence to use a short, memorable one. The salt
* defeats precomputation; it does nothing against guessing, and the cost
* parameters below are deliberately cheap (a few milliseconds, since the
* derivation runs on every read and write). Anyone who steals
* `secrets.json` can therefore try candidate passphrases quickly and
* offline. Use a high-entropy value — generated, not chosen — the same as
* you would for any other credential.
*
* **Availability contract — identical to `KeyringSecretStore`'s**, because
* callers must not have to know which store they got. `get` is tolerant
@ -183,11 +191,47 @@ export class FileSecretStore implements SecretStore {
this.passphrase = raw && raw.trim() ? raw : undefined;
}
/** True when this store encrypts. Drives the `plaintext` descriptor flag. */
/**
* True when the *next write* will encrypt. This is the configured policy,
* not the state of the file — see {@link readOnDiskEncryption}, which is
* what the user-facing descriptor is built from.
*/
get encrypted(): boolean {
return this.passphrase !== undefined;
}
/**
* What the file on disk is *currently* written as — `null` when there is
* no file yet, or when it cannot be read or parsed.
*
* Separate from {@link encrypted} because the two genuinely disagree for a
* whole session: adding `MCP_INSPECTOR_SECRET_KEY` to an install that
* already has a plaintext file flips `encrypted` to true immediately,
* while the bytes stay readable until the next `set`. A descriptor built
* from the policy would tell that user "File (encrypted)" while their
* existing secrets sat in the clear — the precise reassurance this whole
* subsystem exists not to give.
*
* Reads the envelope only. It never decrypts, so it needs no passphrase
* and answers correctly even when the key is wrong or missing — which is
* also a state the UI has to describe rather than throw on.
*/
async readOnDiskEncryption(): Promise<SecretFile["encryption"] | null> {
try {
const raw = await readStoreFile(this.filePath);
if (raw === null) return null;
const parsed = JSON.parse(raw) as SecretFile;
return parsed.encryption === "none" || parsed.encryption === CIPHER
? parsed.encryption
: null;
} catch {
// Absent, unreadable, or not JSON. "Unknown" is the honest answer, and
// the caller falls back to reporting the configured policy — the only
// thing that is knowable about a file nobody can read.
return null;
}
}
private async deriveKey(kdf: KdfParams): Promise<Buffer> {
return scrypt(
// Only reached when the passphrase is set — every caller checks first,
@ -221,10 +265,26 @@ export class FileSecretStore implements SecretStore {
);
}
// Version first, before either branch. A `{ version: 2, encryption:
// "none" }` file is readable as far as the plaintext branch is concerned,
// but reading it means the next write rewrites it as version 1 — silently
// discarding whatever fields version 2 added. That is exactly the
// destroy-to-satisfy-an-additive-request case the encrypted branch already
// refuses, so it gets the same answer rather than a different one that
// happens to depend on which encryption mode the newer writer chose.
if (parsed.version !== FORMAT_VERSION) {
throw new SecretStoreUnavailableError(
`The secrets file at ${this.filePath} declares format version ${String(parsed.version)}, but this Inspector only understands version ${FORMAT_VERSION}. It was probably written by a newer Inspector; upgrade, or move the file aside to start over.`,
);
}
if (parsed.encryption === "none") {
// A plaintext file read by a store that now has a passphrase is fine and
// deliberately not an error: the next write re-encrypts it, so setting
// the env var upgrades an existing file in place.
// the env var upgrades an existing file in place. Until that write
// lands the file really is still readable, which is why
// `readOnDiskEncryption` exists — the descriptor must report the file,
// not the intent.
return { ...(parsed.secrets ?? {}) };
}