inspector/core/auth/node/file-secret-store.ts
cliffhall bc408f5869 feat(auth): file-backed and in-memory SecretStore for hosts with no keychain
Closes #1950

Before this, a box with no reachable OS keychain — the published
container (no D-Bus session, #1848), Android/Termux (no platform binary,
#1905), a minimal Linux install without libsecret — could not persist an
OAuth client secret or a stdio `env:` value at all. `KeyringSecretStore`
degraded into a store that silently holds nothing: `get` returned null,
`delete` no-opped, and `set` hard-failed with a 503. That is the correct
failure behavior, but it left the documented, supported way to run the
Inspector with no secret persistence whatsoever.

Adds the two missing implementations and, more importantly, the policy
that picks one and the surfaces that say which was picked.

- `FileSecretStore` — one `0600` JSON document at
  `~/.mcp-inspector/secrets.json`, encrypted with AES-256-GCM whenever
  `MCP_INSPECTOR_SECRET_KEY` is set (scrypt, per-file random salt). The
  whole map is encrypted as a unit rather than value-by-value so the
  account names — `${serverId}:${field}` — are hidden too; a per-value
  scheme would leave a readable index of which servers you hold a client
  secret for. A file that can no longer be decrypted reads as empty
  (matching the interface's read tolerance) but *refuses to be written*,
  because replacing a file of still-valid secrets to satisfy an additive
  request destroys data.

- `secret-store-selection.ts` — the policy.
  `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` wins outright;
  otherwise probe the keychain and, if unreachable, fall back loudly. The
  fallback is `memory` in a container whose secrets directory is not on a
  mount, and `file` otherwise — so mounting the volume the README already
  recommends for the catalog flips the same run to durable storage with
  no configuration, and an unmounted container gets the honest answer
  rather than a file `--rm` will discard. Cached per process so the
  banner, `/api/config`, and the store doing the writing cannot disagree.

- Automatic, but never silent. The downgrade prints at startup, rides
  `GET /api/config`, and is named in a permanent 32px footer at the
  bottom of both dialogs that accept a secret. A startup banner is seen
  once by whoever started the process; a toast is seen once; a
  dismissible banner is the thing a user dismisses before doing the work
  it describes. The plaintext and memory cases render their caveat inline
  in a warning tone — a warning you must hover to discover is not one.

- `SecretStoreUnavailableError` is now the base every store's `set`
  throws, so the routes translate one condition into a 503 instead of
  matching `KeychainUnavailableError` by name.

Docs: the container section of the root README gains a table of which
store you get and how to make secrets survive, plus the
`MCP_INSPECTOR_SECRET_KEY` upgrade/mismatch rules.

Tests: the existing interface contract block is extracted to
`secretStoreContract.ts` and run against all three implementations, so
none can drift on the availability contract. `FileSecretStore` is tested
against a real temp filesystem — mode, cross-instance round-trip, that
the ciphertext does not contain the plaintext, the read-modify-write
race, and every refusal path. Full `npm run ci` passes, per-file gate
included.

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

400 lines
16 KiB
TypeScript

/**
* File-backed {@link SecretStore} — the persistence answer for a box with
* no reachable OS keychain (#1950): the published container (no D-Bus
* session, #1848), Android/Termux (no platform binary, #1905), and a
* minimal Linux install without libsecret.
*
* **This partially reverses #1356 on purpose.** That change moved
* `oauthClientSecret` *out of* `mcp.json` and into the keychain to get it
* off disk. Writing secrets back to disk here is therefore a decision, not
* an oversight, and it is bounded three ways: the file is separate from
* `mcp.json` (so a user pasting their catalog into an issue does not paste
* their secrets), it is written `0600`, and it is encrypted whenever
* `MCP_INSPECTOR_SECRET_KEY` is set. What it buys is the alternative:
* before this, those users could not persist a secret at all — `set` threw
* and the route answered 503.
*
* **Format.** One JSON document, whose whole secret map is encrypted as a
* unit rather than value-by-value:
*
* ```json
* { "version": 1, "encryption": "none", "secrets": { "srv:field": "s3cr3t" } }
* { "version": 1, "encryption": "aes-256-gcm",
* "kdf": { "algorithm": "scrypt", "salt": "…", "N": 16384, "r": 8, "p": 1 },
* "data": "<iv>.<authTag>.<ciphertext>" }
* ```
*
* Encrypting the map as a unit — not each value — is what hides the
* *account names* too. Those are `${serverId}:${field}`, so a per-value
* scheme would leave a readable index of which servers you use and which
* of them you hold an OAuth client secret for. That index is worth
* roughly as much to an attacker as some of the values.
*
* **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.
*
* **Availability contract — identical to `KeyringSecretStore`'s**, because
* callers must not have to know which store they got. `get` is tolerant
* (returns `null` on any failure), `delete` no-ops, and `set` is the only
* operation that hard-fails, throwing {@link SecretStoreUnavailableError}
* — the moment where a value would actually be lost. Routes translate that
* to a 503 the same way they already do for the keychain.
*
* The one failure worth calling out is the *key mismatch*: a file written
* encrypted, opened later with the passphrase changed or removed. Reads go
* quiet (`null`, per the contract) but writes must not — writing would
* replace a file full of still-valid secrets with one holding a single new
* one, destroying data to satisfy a request that was only ever additive.
* So `set` refuses and says which of the two things to fix.
*/
import * as crypto from "node:crypto";
import * as fs from "node:fs/promises";
import { readStoreFile, writeStoreFile } from "../../storage/store-io.js";
import {
SecretStoreUnavailableError,
type SecretStore,
} from "./secret-store.js";
/**
* Promisified `scrypt`. Hand-wrapped rather than `promisify`d because the
* overload carrying the cost parameters is not the one `promisify`'s types
* pick up, and the alternative — `scryptSync` — blocks the event loop for
* the duration of the derivation on every read and write.
*/
const scrypt = (
passphrase: string,
salt: Buffer,
keylen: number,
options: crypto.ScryptOptions,
): Promise<Buffer> =>
new Promise((resolve, reject) => {
crypto.scrypt(passphrase, salt, keylen, options, (err, key) => {
/* v8 ignore next -- @preserve: node validates the cost parameters
synchronously (an out-of-grammar `N` throws from the call itself,
which the corrupt-KDF test exercises), so this asynchronous channel
is left for allocation failure — not provokable deterministically. */
if (err) reject(err);
else resolve(key);
});
});
const FORMAT_VERSION = 1;
const CIPHER = "aes-256-gcm";
const KEY_BYTES = 32;
const IV_BYTES = 12;
/** scrypt defaults. `N` is the only one that meaningfully costs time; 2^14
* derives in a few milliseconds, which is invisible next to the file I/O it
* accompanies and is derived once per read/write rather than per secret. */
const SCRYPT_N = 16384;
const SCRYPT_R = 8;
const SCRYPT_P = 1;
/** Env var holding the passphrase. Absent → the file is written in the clear. */
export const SECRET_KEY_ENV = "MCP_INSPECTOR_SECRET_KEY";
interface KdfParams {
algorithm: "scrypt";
salt: string;
N: number;
r: number;
p: number;
}
interface SecretFile {
version: number;
encryption: "none" | "aes-256-gcm";
kdf?: KdfParams;
secrets?: Record<string, string>;
data?: string;
}
const buildAccount = (serverId: string, field: string): string =>
`${serverId}:${field}`;
/**
* Thrown when a file was written encrypted and the current passphrase
* cannot open it. A distinct type so `set` can refuse with advice that
* fits, instead of the generic "could not write" that would send someone
* looking at permissions.
*/
export class SecretFileKeyMismatchError extends SecretStoreUnavailableError {
constructor(filePath: string, hasKey: boolean) {
super(
hasKey
? `The secrets file at ${filePath} could not be decrypted with the current ${SECRET_KEY_ENV}. Refusing to write, which would overwrite the existing secrets. Restore the original passphrase, or delete the file to start over.`
: `The secrets file at ${filePath} is encrypted but ${SECRET_KEY_ENV} is not set. Refusing to write, which would overwrite the existing secrets. Set the passphrase this file was written with, or delete the file to start over.`,
);
this.name = "SecretFileKeyMismatchError";
}
}
const encodeParts = (iv: Buffer, tag: Buffer, body: Buffer): string =>
`${iv.toString("base64")}.${tag.toString("base64")}.${body.toString("base64")}`;
/**
* Split a `<iv>.<tag>.<ciphertext>` payload, rejecting anything that is not
* exactly three parts. `split(".")` on a truncated or extended payload
* otherwise yields buffers of the wrong length, and `createDecipheriv`
* reports that as a generic error further away from the cause.
*/
function decodeParts(
payload: string,
): { iv: Buffer; tag: Buffer; body: Buffer } | null {
const parts = payload.split(".");
if (parts.length !== 3) return null;
const [iv, tag, body] = parts as [string, string, string];
return {
iv: Buffer.from(iv, "base64"),
tag: Buffer.from(tag, "base64"),
body: Buffer.from(body, "base64"),
};
}
export interface FileSecretStoreOptions {
/** Absolute path of the secrets file. */
filePath: string;
/**
* Passphrase. Defaults to `process.env[SECRET_KEY_ENV]`; an empty or
* whitespace-only value counts as absent, since `MCP_INSPECTOR_SECRET_KEY=`
* in a compose file is a user who meant "off", not a one-character key.
*/
passphrase?: string;
}
export class FileSecretStore implements SecretStore {
readonly filePath: string;
private readonly passphrase: string | undefined;
/**
* Serializes read-modify-write cycles within this process. `writeStoreFile`
* already makes each individual write atomic and ordered, but that is not
* enough here: two concurrent `set`s would each read the same "before" map
* and the second write would drop the first one's entry. The route handlers
* write a server's fields with `Promise.all`, so this is the ordinary path,
* not a race that needs contriving.
*/
private queue: Promise<unknown> = Promise.resolve();
constructor(options: FileSecretStoreOptions) {
this.filePath = options.filePath;
const raw = options.passphrase ?? process.env[SECRET_KEY_ENV];
this.passphrase = raw && raw.trim() ? raw : undefined;
}
/** True when this store encrypts. Drives the `plaintext` descriptor flag. */
get encrypted(): boolean {
return this.passphrase !== undefined;
}
private async deriveKey(kdf: KdfParams): Promise<Buffer> {
return scrypt(
// Only reached when the passphrase is set — every caller checks first,
// which is what makes the non-null assertion safe here rather than a
// widened `string | undefined` threaded through the crypto helpers.
this.passphrase as string,
Buffer.from(kdf.salt, "base64"),
KEY_BYTES,
{ N: kdf.N, r: kdf.r, p: kdf.p },
);
}
/**
* Read and decode the file into a plain map.
*
* Distinguishes three outcomes that callers treat differently: `null` for
* "no file yet" (a first write should create one), a map for success, and a
* throw for "there is a file and we cannot read it" — which must never be
* flattened into an empty map, or `set` would cheerfully overwrite it.
*/
private async readMap(): Promise<Record<string, string> | null> {
const raw = await readStoreFile(this.filePath);
if (raw === null) return null;
let parsed: SecretFile;
try {
parsed = JSON.parse(raw) as SecretFile;
} catch (err) {
throw new SecretStoreUnavailableError(
`The secrets file at ${this.filePath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
);
}
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.
return { ...(parsed.secrets ?? {}) };
}
if (parsed.encryption !== CIPHER || !parsed.kdf || !parsed.data) {
throw new SecretStoreUnavailableError(
`The secrets file at ${this.filePath} declares an unsupported format (encryption="${String(parsed.encryption)}", version=${String(parsed.version)}). It may have been written by a newer Inspector.`,
);
}
if (!this.passphrase)
throw new SecretFileKeyMismatchError(this.filePath, false);
const parts = decodeParts(parsed.data);
if (!parts) throw new SecretFileKeyMismatchError(this.filePath, true);
try {
const key = await this.deriveKey(parsed.kdf);
const decipher = crypto.createDecipheriv(CIPHER, key, parts.iv);
decipher.setAuthTag(parts.tag);
const plain = Buffer.concat([
decipher.update(parts.body),
decipher.final(),
]).toString("utf-8");
return JSON.parse(plain) as Record<string, string>;
} catch {
// GCM authentication failure is what a wrong passphrase looks like, and
// it is indistinguishable from a tampered/corrupt file — both mean "this
// key does not open this file", which is what the message says.
throw new SecretFileKeyMismatchError(this.filePath, true);
}
}
private async writeMap(map: Record<string, string>): Promise<void> {
let file: SecretFile;
if (this.passphrase) {
const kdf: KdfParams = {
algorithm: "scrypt",
// A fresh salt per write, not per file: rewriting with the same salt
// and a fresh IV would still be safe for GCM, but re-salting costs one
// scrypt derivation we were doing anyway and removes the question.
salt: crypto.randomBytes(16).toString("base64"),
N: SCRYPT_N,
r: SCRYPT_R,
p: SCRYPT_P,
};
const key = await this.deriveKey(kdf);
const iv = crypto.randomBytes(IV_BYTES);
const cipher = crypto.createCipheriv(CIPHER, key, iv);
const body = Buffer.concat([
cipher.update(JSON.stringify(map), "utf-8"),
cipher.final(),
]);
file = {
version: FORMAT_VERSION,
encryption: CIPHER,
kdf,
data: encodeParts(iv, cipher.getAuthTag(), body),
};
} else {
file = { version: FORMAT_VERSION, encryption: "none", secrets: map };
}
// `writeStoreFile` creates the parent dir, writes atomically, and sets
// 0600 — the same helper the OAuth token store uses, so the two files on
// disk have identical permissions by construction rather than by
// convention.
await writeStoreFile(this.filePath, `${JSON.stringify(file, null, 2)}\n`);
}
/** Run `fn` with the file to itself, so concurrent callers can't lose writes. */
private serialize<T>(fn: () => Promise<T>): Promise<T> {
const run = this.queue.then(fn);
// Swallow on the chain only — the returned promise still rejects, so a
// failed `set` surfaces to its caller while a later `set` is not poisoned
// by it. This assignment is also why `this.queue` needs no `.catch()` of
// its own above: it is only ever `Promise.resolve()` or an
// already-swallowed promise, so it cannot reject.
this.queue = run.then(
() => {},
() => {},
);
return run;
}
async get(serverId: string, field: string): Promise<string | null> {
try {
const map = await this.serialize(() => this.readMap());
return map?.[buildAccount(serverId, field)] ?? null;
} catch {
// Read tolerance, matching `KeyringSecretStore.get`: an unreadable store
// and an absent entry are the same answer to the caller, and hard-failing
// here would break every `GET /api/servers` on a box whose passphrase
// changed.
return null;
}
}
async set(serverId: string, field: string, value: string): Promise<void> {
await this.serialize(async () => {
let map: Record<string, string>;
try {
map = (await this.readMap()) ?? {};
} catch (err) {
// Already a typed refusal (unreadable file / key mismatch) — rethrow
// rather than re-wrapping, so the specific advice survives.
if (err instanceof SecretStoreUnavailableError) throw err;
throw new SecretStoreUnavailableError(
`Could not read the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`,
);
}
map[buildAccount(serverId, field)] = value;
try {
await this.writeMap(map);
} catch (err) {
throw new SecretStoreUnavailableError(
`Could not write the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`,
);
}
});
}
async delete(serverId: string, field: string): Promise<void> {
await this.deleteWhere((key) => key === buildAccount(serverId, field));
}
async deleteAllForServer(serverId: string): Promise<void> {
const prefix = `${serverId}:`;
await this.deleteWhere((key) => key.startsWith(prefix));
}
/**
* Shared delete body. Silent on every failure, matching the keyring store:
* every reason a delete can fail collapses to "the entry isn't there
* anymore", and `set` is the operation that reports a broken store.
*/
private async deleteWhere(match: (key: string) => boolean): Promise<void> {
try {
await this.serialize(async () => {
const map = await this.readMap();
if (!map) return;
const keys = Object.keys(map).filter(match);
if (keys.length === 0) return;
for (const key of keys) delete map[key];
// An emptied map keeps the file rather than unlinking it: its presence
// is what records which encryption mode this install settled on, so a
// later write can't silently switch modes on the user.
await this.writeMap(map);
});
} catch {
// Intentionally silent — see the doc comment above.
}
}
}
/**
* Best-effort permission repair for a pre-existing secrets file.
*
* `writeStoreFile` passes `mode: 0o600`, but a mode argument only applies
* when the file is *created* — an atomic rename over a file someone made
* `0644` by hand (or restored from a backup, or copied out of an image)
* keeps the loose mode forever. Called once at store selection so the
* guarantee the docs make is the guarantee on disk.
*
* Failures are swallowed: a file we cannot chmod is one we probably cannot
* write either, and `set` is where that gets reported with context.
*/
export async function tightenSecretFilePermissions(
filePath: string,
): Promise<void> {
try {
await fs.chmod(filePath, 0o600);
} catch {
// Non-existent (the common case — nothing written yet), read-only mount,
// or a platform without POSIX modes. Nothing actionable here.
}
}