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:
parent
5f4a6ea09b
commit
37faf1be88
1 changed files with 351 additions and 24 deletions
|
|
@ -65,6 +65,8 @@ import { RemoteSession } from "./remote-session.js";
|
|||
import { createRemoteAuthProvider } from "./tokenAuthProvider.js";
|
||||
import { API_SERVER_ENV_VARS } from "../constants.js";
|
||||
import {
|
||||
secretStoreGetMany,
|
||||
secretStoreGetStrict,
|
||||
secretStoreIsDurable,
|
||||
SecretStoreUnavailableError,
|
||||
type SecretStore,
|
||||
|
|
@ -1899,16 +1901,11 @@ export function createRemoteApp(
|
|||
id: string,
|
||||
fields: string[],
|
||||
): Promise<Record<string, string>> => {
|
||||
const values = await Promise.all(
|
||||
fields.map(
|
||||
async (field) => [field, await secretStore.get(id, field)] as const,
|
||||
),
|
||||
);
|
||||
const out: Record<string, string> = {};
|
||||
for (const [field, v] of values) {
|
||||
if (v !== null) out[field] = v;
|
||||
}
|
||||
return out;
|
||||
// One pass per server rather than one per field — see
|
||||
// `secretStoreGetMany`. Unchanged for the keychain (still independent
|
||||
// parallel round-trips); for the file store it is the difference between
|
||||
// one decryption and one per field.
|
||||
return secretStoreGetMany(secretStore, id, fields);
|
||||
};
|
||||
|
||||
// Cheap structural check used by the GET handler to decide whether
|
||||
|
|
@ -1954,7 +1951,13 @@ export function createRemoteApp(
|
|||
continue;
|
||||
}
|
||||
for (const [field, value] of Object.entries(secrets)) {
|
||||
const existing = await secretStore.get(id, field);
|
||||
// Strict: `get` maps an unreadable store to `null`, and this
|
||||
// branch *writes* on `null` — so a transient keychain read
|
||||
// failure would let the older plaintext value overwrite a newer
|
||||
// keychain one, and the disk copy would then be stripped. A throw
|
||||
// is a `SecretStoreUnavailableError`, which the catch below turns
|
||||
// into "abandon the migration and keep mcp.json as it is".
|
||||
const existing = await secretStoreGetStrict(secretStore, id, field);
|
||||
if (existing === null) {
|
||||
await secretStore.set(id, field, value);
|
||||
}
|
||||
|
|
@ -1982,7 +1985,7 @@ export function createRemoteApp(
|
|||
if (fileLogger) {
|
||||
fileLogger.warn(
|
||||
{ err: err.message },
|
||||
"Keychain unavailable; skipping plaintext-secret migration on this read. Existing mcp.json plaintext values are preserved.",
|
||||
"Secret store unavailable; skipping plaintext-secret migration on this read. Existing mcp.json plaintext values are preserved.",
|
||||
);
|
||||
}
|
||||
// Partial-migration semantics: if `set` threw partway through
|
||||
|
|
|
|||
Loading…
Reference in a new issue