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:
parent
bc408f5869
commit
3452a63101
4 changed files with 411 additions and 75 deletions
|
|
@ -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 ?? {}) };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -61,9 +61,19 @@ import {
|
|||
/** Env var naming an explicit store, bypassing the probe entirely. */
|
||||
export const SECRET_STORE_ENV = "MCP_INSPECTOR_SECRET_STORE";
|
||||
|
||||
/** Env var overriding the secrets file location. */
|
||||
/** Env var overriding the secrets file location outright. */
|
||||
export const SECRET_FILE_ENV = "MCP_INSPECTOR_SECRET_FILE";
|
||||
|
||||
/**
|
||||
* Env var relocating the Inspector's durable state directory. Already
|
||||
* honored by the web backend for OAuth tokens and `client.json`; the
|
||||
* secrets file follows it for the same reason, and because the mount check
|
||||
* below asks whether *that* directory survives. A container mounting only
|
||||
* the configured storage directory would otherwise be judged unmounted,
|
||||
* fall back to `memory`, and lose secrets the user had arranged to keep.
|
||||
*/
|
||||
export const STORAGE_DIR_ENV = "MCP_STORAGE_DIR";
|
||||
|
||||
const KINDS: SecretStoreKind[] = ["keyring", "file", "memory"];
|
||||
|
||||
export interface ResolvedSecretStore {
|
||||
|
|
@ -89,10 +99,16 @@ export function parseSecretStoreEnv(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
/** Default secrets file: beside the catalog, in `~/.mcp-inspector`. */
|
||||
/**
|
||||
* Default secrets file: `MCP_INSPECTOR_SECRET_FILE` if named outright,
|
||||
* otherwise `secrets.json` in the configured storage directory, otherwise
|
||||
* beside the catalog in `~/.mcp-inspector`.
|
||||
*/
|
||||
export function defaultSecretFilePath(): string {
|
||||
const override = process.env[SECRET_FILE_ENV]?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
const storageDir = process.env[STORAGE_DIR_ENV]?.trim();
|
||||
if (storageDir) return path.resolve(storageDir, "secrets.json");
|
||||
const homeDir = process.env.HOME || process.env.USERPROFILE || ".";
|
||||
return path.join(homeDir, ".mcp-inspector", "secrets.json");
|
||||
}
|
||||
|
|
@ -172,11 +188,11 @@ export function isOnMountPoint(dir: string): boolean {
|
|||
}
|
||||
|
||||
/** Build the store named by `kind`, plus the descriptor that explains it. */
|
||||
function buildStore(
|
||||
async function buildStore(
|
||||
kind: SecretStoreKind,
|
||||
reason: SecretStorageInfo["reason"],
|
||||
detail?: string,
|
||||
): ResolvedSecretStore {
|
||||
): Promise<ResolvedSecretStore> {
|
||||
if (kind === "keyring") {
|
||||
return {
|
||||
store: new KeyringSecretStore(),
|
||||
|
|
@ -196,13 +212,24 @@ function buildStore(
|
|||
// every failure), and blocking selection on a chmod would make an
|
||||
// unwritable file a startup problem rather than a `set`-time one.
|
||||
void tightenSecretFilePermissions(filePath);
|
||||
// Report the FILE, not the intent. `store.encrypted` is what the next write
|
||||
// will do; a file already on disk in the clear stays readable until that
|
||||
// write happens, and saying "File (encrypted)" in the meantime is the one
|
||||
// false reassurance this footer must never give. With no file yet (or one we
|
||||
// cannot parse) the policy is all there is to report, and it is then
|
||||
// accurate — the first write creates the file in that mode.
|
||||
const onDisk = await store.readOnDiskEncryption();
|
||||
const plaintext = onDisk === null ? !store.encrypted : onDisk === "none";
|
||||
return {
|
||||
store,
|
||||
info: {
|
||||
kind,
|
||||
reason,
|
||||
path: filePath,
|
||||
plaintext: !store.encrypted,
|
||||
plaintext,
|
||||
// Only meaningful while the two disagree; omitted otherwise so the
|
||||
// payload does not carry a flag that says nothing.
|
||||
...(plaintext && store.encrypted ? { pendingEncryption: true } : {}),
|
||||
durable: true,
|
||||
detail,
|
||||
},
|
||||
|
|
@ -260,7 +287,7 @@ export function resolveSecretStore(): Promise<ResolvedSecretStore> {
|
|||
resolved ??= (async (): Promise<ResolvedSecretStore> => {
|
||||
const configured = parseSecretStoreEnv(process.env[SECRET_STORE_ENV]);
|
||||
if (configured) {
|
||||
const result = buildStore(configured, "configured");
|
||||
const result = await buildStore(configured, "configured");
|
||||
warnAboutSecretStorage(result.info);
|
||||
return result;
|
||||
}
|
||||
|
|
@ -271,7 +298,7 @@ export function resolveSecretStore(): Promise<ResolvedSecretStore> {
|
|||
container: isContainer(),
|
||||
mounted: isOnMountPoint(path.dirname(defaultSecretFilePath())),
|
||||
});
|
||||
const result = buildStore(kind, "fallback", probe.detail);
|
||||
const result = await buildStore(kind, "fallback", probe.detail);
|
||||
warnAboutSecretStorage(result.info);
|
||||
return result;
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -42,12 +42,26 @@ export interface SecretStorageInfo {
|
|||
*/
|
||||
path?: string;
|
||||
/**
|
||||
* True when a file-backed store is writing secrets in the clear (no
|
||||
* `MCP_INSPECTOR_SECRET_KEY`). 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.
|
||||
* 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:
|
||||
|
|
@ -104,7 +118,9 @@ export function secretStorageCaveat(
|
|||
return "Secrets are not written anywhere and are lost when the Inspector exits.";
|
||||
}
|
||||
if (info.plaintext) {
|
||||
return "Secrets are stored unencrypted (file mode 0600). Set MCP_INSPECTOR_SECRET_KEY to encrypt them.";
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,15 +71,34 @@ interface ConfigPayload {
|
|||
*
|
||||
* The footer states, in the UI, where a user's secret is about to go, so a
|
||||
* malformed or partial descriptor must render as "unknown" rather than as a
|
||||
* confident half-answer — the `kind` alone is what every label and tone
|
||||
* derives from, so it is the field the check turns on.
|
||||
* confident half-answer.
|
||||
*
|
||||
* Every field the footer reads is checked, not just `kind`. A partial
|
||||
* `{ kind: "file" }` would otherwise pass, and because a missing `plaintext`
|
||||
* is falsy it would render as the quiet, neutral *encrypted* file — the
|
||||
* single most misleading thing this component can say, produced by the
|
||||
* absence of information rather than by any claim the backend made. Each
|
||||
* kind's own required fields are checked for the same reason: a file store
|
||||
* with no `path` cannot answer the question a file store exists to answer.
|
||||
*/
|
||||
function usableSecretStorage(value: unknown): SecretStorageInfo | undefined {
|
||||
if (typeof value !== "object" || value === null) return undefined;
|
||||
const kind = (value as { kind?: unknown }).kind;
|
||||
if (kind !== "keyring" && kind !== "file" && kind !== "memory") {
|
||||
const v = value as Record<string, unknown>;
|
||||
if (v.kind !== "keyring" && v.kind !== "file" && v.kind !== "memory") {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
v.reason !== "configured" &&
|
||||
v.reason !== "default" &&
|
||||
v.reason !== "fallback"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof v.durable !== "boolean") return undefined;
|
||||
if (v.kind === "file") {
|
||||
if (typeof v.path !== "string" || v.path === "") return undefined;
|
||||
if (typeof v.plaintext !== "boolean") return undefined;
|
||||
}
|
||||
return value as SecretStorageInfo;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue