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>
This commit is contained in:
cliffhall 2026-08-22 17:41:56 -04:00
commit 37faf1be88
3 changed files with 351 additions and 24 deletions

View file

@ -792,6 +792,34 @@ export class FileSecretStore implements SecretStore {
return run;
}
/**
* One read and one derivation for a whole server's fields.
*
* The reason this exists: `get` reads and decrypts the entire file, and
* the rehydration callers ask field by field — so an encrypted catalog
* paid N scrypt derivations per server, serialized behind this store's own
* queue, every time the server list was loaded. Tolerant like `get`: an
* unreadable store yields no fields rather than throwing.
*/
async getMany(
serverId: string,
fields: string[],
): Promise<Record<string, string>> {
let map: Record<string, string> | null;
try {
map = await this.serialize(() => this.readMap());
} catch {
return {};
}
if (!map) return {};
const out: Record<string, string> = {};
for (const field of fields) {
const value = map[buildAccount(serverId, field)];
if (value !== undefined) out[field] = value;
}
return out;
}
async get(serverId: string, field: string): Promise<string | null> {
try {
const map = await this.serialize(() => this.readMap());
@ -870,6 +898,10 @@ export class FileSecretStore implements SecretStore {
/**
* Best-effort permission repair for a pre-existing secrets file.
*
* A no-op in effect on Windows, where `chmod` cannot express "owner only"
* — {@link readSecretFilePermissions} reports `unknown` there rather than
* interpreting the synthetic mode bits.
*
* `writeStoreFile` writes through `atomically`, which creates a temp file
* with `mode: 0o600` and renames it over the destination — so every *write*
* re-establishes the mode regardless of what the old file had. What it
@ -911,6 +943,17 @@ export async function readSecretFilePermissions(
filePath: string,
): Promise<SecretFilePermissionState> {
try {
// Windows does not model POSIX modes. `stat` still returns *something*
// — synthetic bits derived from the read-only attribute — and reading
// them as a permission statement produces a confident falsehood either
// way: "0666 — not owner-only" about a file the ACL may well restrict,
// or "owner-only" about one it does not. The real answer lives in the
// ACL, which this does not inspect, so the honest report is that we did
// not check.
if (process.platform === "win32") {
await fs.stat(filePath); // still distinguishes absent from present
return { state: "unknown", detail: "not checked on Windows" };
}
const mode = (await fs.stat(filePath)).mode & 0o777;
if (mode === 0o600) return { state: "ok" };
return { state: "loose", mode };

View file

@ -58,6 +58,7 @@ import {
KeyringSecretStore,
parseAccount,
probeKeyringAvailable,
secretStoreGetMany,
secretStoreGetStrict,
secretStoreIsDurable,
SessionSecretStore,
@ -615,6 +616,21 @@ class DeferredSecretStore implements SecretStore {
async isDurable(): Promise<boolean> {
return secretStoreIsDurable(await this.target());
}
/**
* Forwarded, and its absence was a real defect rather than an omission:
* this is the store production actually uses, so a `secretStoreGetStrict`
* call against it fell through to the tolerant `get` and the strictness
* existed only in tests that injected a concrete store.
*/
async getStrict(serverId: string, field: string): Promise<string | null> {
return secretStoreGetStrict(await this.target(), serverId, field);
}
async getMany(
serverId: string,
fields: string[],
): Promise<Record<string, string>> {
return secretStoreGetMany(await this.target(), serverId, fields);
}
async get(serverId: string, field: string): Promise<string | null> {
return (await this.target()).get(serverId, field);
}

View file

@ -337,6 +337,20 @@ export interface SecretStore {
* absent, which is correct for stores whose reads cannot fail.
*/
getStrict?(serverId: string, field: string): Promise<string | null>;
/**
* Read several of one server's fields in a single pass.
*
* Optional, and purely a performance seam: {@link secretStoreGetMany}
* falls back to a parallel `get` per field, which is what the keychain
* wants anyway (independent native round-trips).
*
* It exists for `FileSecretStore`, where the shape is inverted — every
* `get` reads and decrypts the *whole* file, so N fields cost N scrypt
* derivations, serialized behind the store's own queue. The rehydration
* callers ask for one server's fields at a time, so a per-server bulk read
* turns that back into one.
*/
getMany?(serverId: string, fields: string[]): Promise<Record<string, string>>;
set(serverId: string, field: string, value: string): Promise<void>;
/** No-op if no entry exists. */
delete(serverId: string, field: string): Promise<void>;
@ -514,6 +528,29 @@ export async function secretStoreGetStrict(
: store.get(serverId, field);
}
/**
* Read several fields of one server, using the store's bulk path when it has
* one. Returns only the fields that are present, matching the callers that
* merge the result into a stored config.
*/
export async function secretStoreGetMany(
store: SecretStore,
serverId: string,
fields: string[],
): Promise<Record<string, string>> {
if (store.getMany) return store.getMany(serverId, fields);
const entries = await Promise.all(
fields.map(
async (field) => [field, await store.get(serverId, field)] as const,
),
);
const out: Record<string, string> = {};
for (const [field, value] of entries) {
if (value !== null) out[field] = value;
}
return out;
}
export async function secretStoreIsDurable(
store: SecretStore,
): Promise<boolean> {