inspector/core/client/node-persistence.ts
cliffhall 5ef3572578 fix(auth): stop losing preserved plaintext, and claim the hand-off file atomically
Round 19 found real data loss rather than process noise, in two places.

**An unrelated settings edit destroyed the secret the durability gate had
just preserved.** The GET migration withholds its strip for a session-scoped
store, so `mcp.json` stays the durable copy — but the POST/PUT paths always
wrote the stripped shape. The GET also returns the rehydrated secret to the
settings form, which resends the whole object when the user changes anything,
so editing a timeout wrote the stripped entry, moved the only durable copy
into RAM, and lost it at exit. Every operation reported success. The client
config had the identical shape: the read-path guard was there, the write path
had none, and saving a CIMD URL did the same thing to the IdP client secret.

Both write paths now agree with their read paths: while the store cannot
outlive the process, the file stays the durable copy.

**The hand-off's delete is no longer a check-to-delete race.** Re-reading
immediately before `rm` caught writes that had already landed and was blind to
the one arriving between the check and the delete — destroying a *later*,
already-successful write, which is worse than the optimistic-write residual
and, unlike it, fixable without a lock. The file is claimed by an atomic
rename before anything reads it, so the migration operates on a snapshot
nobody else can reach and a writer that recreates `secrets.json` is simply
untouched.

Claiming introduced a failure mode the existing tests caught immediately: a
migration that aborts must not leave the secrets stranded at the staging path.
The snapshot is restored on any incomplete outcome, and when the live path has
been recreated in the meantime it is left in place with its location logged —
an operator-visible orphan is recoverable; clobbering someone's write is not.

Also:

- **`setMany`**, the write-side twin of `getMany`. `set` costs a whole-file
  read-decrypt-encrypt-write-verify cycle per call, and the settings form
  resends a server's full `env` map on any edit, so a 10-variable stdio server
  spent ~30 scrypt derivations saving an unrelated change. One cycle now; the
  keychain keeps its parallel individual writes.
- **A doc that contradicted its function.** `readOnDiskEncryption` still said
  it "never decrypts, so it needs no passphrase" — round 12 made it
  authenticate precisely because structure is not access.

Full `npm run ci` green, staged tree verified before running it.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 21:34:34 -04:00

145 lines
4.8 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 : validated),
);
}
/** 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);
}