Round 20, and almost all of it is fallout from round 19's two changes. Both were right in direction and wrong in the detail, in the same way: each solved its case by treating a broad thing as a proxy for a narrow one. **The durability guard wrote newly entered secrets to disk in plaintext.** Preserving legacy plaintext for a session-scoped store is correct — stripping it moves the only durable copy into RAM. Using *the whole submitted entry* as the proxy for "legacy" was not: it turned `MCP_INSPECTOR_SECRET_STORE=memory` into "write every new secret to `mcp.json` in the clear", while the footer for that very store says secrets are written nowhere. A security regression introduced by a data-loss fix, and the same overshoot in `client.json`. Provenance decides now. A secret field is kept on disk only when it was already there, unchanged, in the file being overwritten; a newly entered or changed value is stripped and lives in the session store, which is exactly what the footer promises. Where provenance cannot be established — an unparseable prior file — the value is treated as new, because writing a secret to disk on a guess is the failure that matters. **The atomic claim was not as safe as its comments said.** Four ways: - A crash between the claim and the copy left only `secrets.json.migrating-<pid>`. The next run checked the canonical path, found nothing, selected the keychain, and every stored credential vanished with no warning. Orphans are now adopted at startup. - Restoring used `rename`, which on POSIX **replaces** its destination — so the restore path could overwrite a `secrets.json` a concurrent writer had recreated, destroying the later successful write that claiming exists to protect. The comment beside it asserted the opposite. `link` + `unlink` is the no-clobber primitive this needed. - Every claim failure was read as a lost race. `EACCES`/`EROFS` leave the file exactly where it is, and returning quietly selected the keychain while file-backed secrets sat there invisible. Only `ENOENT` is benign now. - A failed cleanup left a plaintext-capable snapshot on disk with a silent catch, while the app reported the keychain as the only store. It names the path now. **A third secret-entry surface had no disclosure at all.** `ServerConfigModal` takes stdio `env` values, which are extracted into the store exactly as Server Settings' are — and the footer's own doc comment claimed there were only two such dialogs. It has the footer now, and every server write that can carry a secret (config submit, both import flows) refreshes the descriptor, not just the settings save. Full `npm run ci` green, staged tree verified first. Signed-off-by: cliffhall <cliff@futurescale.com>
189 lines
6.2 KiB
TypeScript
189 lines
6.2 KiB
TypeScript
/**
|
|
* Node-only client.json persistence with OS keychain for IdP clientSecret.
|
|
*/
|
|
|
|
import {
|
|
secretStoreGetStrict,
|
|
secretStoreIsDurable,
|
|
SecretStoreUnavailableError,
|
|
type SecretStore,
|
|
} from "../auth/node/secret-store.js";
|
|
import { SECRET_FIELD_IDP_CLIENT_SECRET } from "../auth/secret-fields.js";
|
|
import {
|
|
deleteStoreFile,
|
|
parseStore,
|
|
readStoreFile,
|
|
serializeStore,
|
|
writeStoreFile,
|
|
} from "../storage/store-io.js";
|
|
import { parseClientConfig } from "./config-parse.js";
|
|
import type { ClientConfig } from "./types.js";
|
|
import {
|
|
CLIENT_KEYCHAIN_ID,
|
|
extractSecretsFromClientConfig,
|
|
hasClientPlaintextSecret,
|
|
mergeSecretsIntoClientConfig,
|
|
} from "./secrets.js";
|
|
|
|
async function readIdpSecretFromKeychain(
|
|
secretStore: SecretStore,
|
|
): Promise<Record<string, string>> {
|
|
const secret = await secretStore.get(
|
|
CLIENT_KEYCHAIN_ID,
|
|
SECRET_FIELD_IDP_CLIENT_SECRET,
|
|
);
|
|
if (!secret) return {};
|
|
return { [SECRET_FIELD_IDP_CLIENT_SECRET]: secret };
|
|
}
|
|
|
|
async function migrateClientPlaintextSecret(
|
|
filePath: string,
|
|
config: ClientConfig,
|
|
secretStore: SecretStore,
|
|
): Promise<ClientConfig> {
|
|
const { stripped, secrets } = extractSecretsFromClientConfig(config);
|
|
const value = secrets[SECRET_FIELD_IDP_CLIENT_SECRET];
|
|
if (!value) return config;
|
|
|
|
try {
|
|
// Strict: `get` answers `null` for an unreadable store as well as a
|
|
// missing entry, and the branch below *writes* on `null` — so a
|
|
// transient failure would overwrite a newer stored secret with the older
|
|
// `client.json` copy, inverting keychain-wins. A throw is caught below
|
|
// and leaves the plaintext file untouched for the next attempt.
|
|
const existing = await secretStoreGetStrict(
|
|
secretStore,
|
|
CLIENT_KEYCHAIN_ID,
|
|
SECRET_FIELD_IDP_CLIENT_SECRET,
|
|
);
|
|
if (existing === null) {
|
|
await secretStore.set(
|
|
CLIENT_KEYCHAIN_ID,
|
|
SECRET_FIELD_IDP_CLIENT_SECRET,
|
|
value,
|
|
);
|
|
}
|
|
// Only strip the plaintext once it is somewhere that outlives us.
|
|
// Against a session-scoped store (the container fallback added in
|
|
// #1950) this migration would trade a secret that survives restarts
|
|
// for one that dies with the process — and it runs on an ordinary
|
|
// read, so merely loading the app would destroy it. The value is
|
|
// still loaded into the store above, so this session behaves
|
|
// normally; only the delete is withheld.
|
|
if (!(await secretStoreIsDurable(secretStore))) return config;
|
|
await writeStoreFile(filePath, serializeStore(stripped));
|
|
return stripped;
|
|
} catch (err) {
|
|
if (err instanceof SecretStoreUnavailableError) {
|
|
return config;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/** Read client.json from disk and rehydrate IdP clientSecret from the keychain. */
|
|
export async function readClientConfigStore(
|
|
filePath: string,
|
|
secretStore: SecretStore,
|
|
): Promise<ClientConfig> {
|
|
const raw = await readStoreFile(filePath);
|
|
if (raw === null) {
|
|
return {};
|
|
}
|
|
|
|
let config = parseClientConfig(parseStore(raw));
|
|
if (hasClientPlaintextSecret(config)) {
|
|
config = await migrateClientPlaintextSecret(filePath, config, secretStore);
|
|
}
|
|
|
|
const secrets = await readIdpSecretFromKeychain(secretStore);
|
|
return mergeSecretsIntoClientConfig(config, secrets);
|
|
}
|
|
|
|
/** Validate, strip IdP clientSecret to keychain, and write client.json. */
|
|
export async function writeClientConfigStore(
|
|
filePath: string,
|
|
body: unknown,
|
|
secretStore: SecretStore,
|
|
): Promise<void> {
|
|
const validated = parseClientConfig(body);
|
|
const { stripped, secrets } = extractSecretsFromClientConfig(validated);
|
|
const idpSecret = secrets[SECRET_FIELD_IDP_CLIENT_SECRET];
|
|
if (idpSecret) {
|
|
await secretStore.set(
|
|
CLIENT_KEYCHAIN_ID,
|
|
SECRET_FIELD_IDP_CLIENT_SECRET,
|
|
idpSecret,
|
|
);
|
|
} else {
|
|
await secretStore.delete(
|
|
CLIENT_KEYCHAIN_ID,
|
|
SECRET_FIELD_IDP_CLIENT_SECRET,
|
|
);
|
|
}
|
|
// What actually goes to disk. The read-path migration already withholds
|
|
// the strip for a session-scoped store, but the *write* path did not — so
|
|
// saving any unrelated field (a CIMD URL, an issuer) round-tripped the
|
|
// rehydrated secret through the form and then wrote the stripped shape,
|
|
// moving the only durable copy into RAM to be lost at exit. The two paths
|
|
// have to agree: while the store cannot outlive the process, `client.json`
|
|
// stays the durable copy.
|
|
const durable = await secretStoreIsDurable(secretStore);
|
|
await writeStoreFile(
|
|
filePath,
|
|
serializeStore(
|
|
durable
|
|
? stripped
|
|
: await preserveLegacyPlaintext(
|
|
filePath,
|
|
validated,
|
|
stripped,
|
|
idpSecret,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* For a session-scoped store: keep the IdP secret on disk only when it was
|
|
* **already there, unchanged**.
|
|
*
|
|
* Stripping legacy plaintext would move the only durable copy into RAM (the
|
|
* read-path migration withholds its strip for exactly this reason, and the
|
|
* write path has to agree). But writing a *newly entered* secret to disk
|
|
* would contradict the footer, which promises a session store writes secrets
|
|
* nowhere — so provenance decides, not the presence of a value in the
|
|
* submitted body.
|
|
*/
|
|
async function preserveLegacyPlaintext(
|
|
filePath: string,
|
|
validated: ClientConfig,
|
|
stripped: ClientConfig,
|
|
idpSecret: string | undefined,
|
|
): Promise<ClientConfig> {
|
|
if (!idpSecret) return stripped;
|
|
try {
|
|
const raw = await readStoreFile(filePath);
|
|
if (raw === null) return stripped;
|
|
const prior = parseClientConfig(parseStore(raw));
|
|
const priorSecret =
|
|
extractSecretsFromClientConfig(prior).secrets[
|
|
SECRET_FIELD_IDP_CLIENT_SECRET
|
|
];
|
|
return priorSecret === idpSecret ? validated : stripped;
|
|
} catch {
|
|
// Unreadable or unparseable prior file: treat the value as new, which is
|
|
// the conservative direction — it keeps the secret off disk rather than
|
|
// writing it there on a guess.
|
|
return stripped;
|
|
}
|
|
}
|
|
|
|
/** Remove client.json and the install-level IdP secret from the keychain. */
|
|
export async function deleteClientConfigStore(
|
|
filePath: string,
|
|
secretStore: SecretStore,
|
|
): Promise<void> {
|
|
await deleteStoreFile(filePath);
|
|
await secretStore.delete(CLIENT_KEYCHAIN_ID, SECRET_FIELD_IDP_CLIENT_SECRET);
|
|
}
|