inspector/core/client/node-persistence.ts
cliffhall 37faf1be88 fix(auth): address Copilot review round 8 (four inline findings)
The lock finding from this round is deliberately not addressed here — see
below.

- **My round-6 strict-read fix was inert in production.** `DeferredSecretStore`
  is what `defaultSecretStore()` returns, and it did not forward `getStrict`
  — so `secretStoreGetStrict` fell through to the tolerant `get` for every
  real caller, and the strictness existed only in tests that injected a
  concrete store. Forwarded now, along with `getMany`.

- **The same tolerant-read bug remained in both pre-existing migrations.**
  I added `getStrict` for the new hand-off in round 6 and never applied it to
  the mcp.json and client.json migrations, which have the identical
  shape: decide from `get`, then write on `null`. A transient keychain read
  failure therefore let an older plaintext value overwrite a newer keychain
  one — and the disk copy was then stripped, so the newer value was gone. Both
  use the strict read now; the throw is a `SecretStoreUnavailableError`, which
  both call sites already handle as "abandon the migration, keep the file".

- **The permission check made a false statement on Windows.** `stat` returns
  synthetic bits there, and interpreting them yields a confident falsehood
  either way — "0666 — not owner-only" about a file the ACL may restrict, or
  "owner-only" about one it does not. The ACL is the real answer and this does
  not inspect it, so Windows now reports `unknown`.

- **Rehydration cost one full decryption per field.** `get` reads and decrypts
  the entire file, and both rehydration paths ask field by field, serialized
  behind the store's own queue. Measured on this machine, scrypt at N=16384 is
  ~23ms per derivation, so a 20-secret encrypted catalog spent **~450ms of
  pure key derivation on every `GET /api/servers`** — a visible stall, not a
  micro-optimization. Added an optional `getMany` seam: `FileSecretStore`
  decrypts once per server, everything else falls back to the parallel
  per-field `get` that already suited it. A test counts derivations directly:
  1 for a three-field `getMany`, 3 for the three `get`s it replaced.

Also broadens a log message that still said "Keychain unavailable" for a
store that may not be a keychain.

Full `npm run ci` green — 5,762 tests under the coverage gate.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 17:41:56 -04:00

134 lines
4.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,
);
}
await writeStoreFile(filePath, serializeStore(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);
}