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
2 changed files with 351 additions and 24 deletions
|
|
@ -991,7 +991,9 @@ describe("server.ts supplemental coverage", () => {
|
|||
const warned = cap.records.filter(
|
||||
(r) =>
|
||||
r.level === "warn" &&
|
||||
JSON.stringify(r.args).includes("Keychain unavailable"),
|
||||
// Wording broadened with the store: a file-backed store fails for
|
||||
// reasons that have nothing to do with a keychain.
|
||||
JSON.stringify(r.args).includes("Secret store unavailable"),
|
||||
);
|
||||
expect(warned.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -2674,6 +2674,37 @@ describe("plaintext migration against a session-scoped store (#1950)", () => {
|
|||
expect(readFileSync(configPath, "utf-8")).toBe(before);
|
||||
});
|
||||
|
||||
it("aborts the migration when the keychain read fails, preserving mcp.json", async () => {
|
||||
// The keychain-wins lookup used the tolerant `get`, so an unreadable
|
||||
// keychain read as "nothing there" — and the write that followed would
|
||||
// put the older plaintext over a newer keychain value before the disk
|
||||
// copy was stripped.
|
||||
let wrote = false;
|
||||
const flaky: SecretStore = {
|
||||
get: async () => null,
|
||||
getStrict: async () => {
|
||||
throw new KeychainUnavailableError(new Error("temporarily down"));
|
||||
},
|
||||
set: async () => {
|
||||
wrote = true;
|
||||
},
|
||||
delete: async () => {},
|
||||
deleteAllForServer: async () => {},
|
||||
};
|
||||
const { app } = createRemoteApp({
|
||||
dangerouslyOmitAuth: true,
|
||||
mcpConfigPath: configPath,
|
||||
initialConfig: { defaultEnvironment: {} },
|
||||
secretStore: flaky,
|
||||
});
|
||||
const before = readFileSync(configPath, "utf-8");
|
||||
|
||||
const res = await app.request(new Request("http://test/api/servers"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(wrote).toBe(false);
|
||||
expect(readFileSync(configPath, "utf-8")).toBe(before);
|
||||
});
|
||||
|
||||
it("still strips it against a durable store, so the guard is the difference", async () => {
|
||||
// The control: same config, same request, a store that outlives the
|
||||
// process — and the migration does what it always did.
|
||||
|
|
|
|||
Loading…
Reference in a new issue