fix(auth): replace the secrets-file lock with optimistic verify-and-retry, and address review round 9

Two changes, interleaved in the same files because both are about the
same read-modify-write path.

## The lock is gone (maintainer decision on the round-5/7/8 finding)

Three consecutive review rounds found a real race in the `mkdir` lock, and
the last one is not patchable with what Node exposes: claiming a stale lock
needs compare-and-swap on a directory entry (`renameat2(RENAME_EXCHANGE)`).
Without it, `canSteal` inspects one lock but `rename` acts on whatever is at
that path *later*, so a waiter that lost the first race can move the
winner's fresh lock aside and enter alongside it.

Rather than patch it a fourth time, writers now collide and the loser
notices. `mutate` reads `M0`, applies the mutation to get `M1`, writes it,
then reads back `M2`; if they differ, someone wrote in between, so it
re-applies onto what they left and retries, bounded.

The comparison is over the **whole map**, and that is the load-bearing
detail. Checking only the entry we touched passes in exactly the case that
loses data — ours is present, and it is the *other* writer's that is gone.
With A and B from the same `M0`: A writes `MA`, B writes `MB` and clobbers
A; B verifies and correctly sees `MB`; A verifies, sees `M2 !== MA`,
re-applies onto `MB`, writes again. Both survive, and the writer that was
clobbered is the one that repairs it.

Two things this deliberately does not claim:

- It is **not mutual exclusion**. A process that crashes between its write
  and its verifying read cannot notice it was clobbered, and nothing else
  will. That window is strictly narrower than the lock's — which lost
  updates with every participant alive — and needs no primitive Node lacks.
  Stated in the code rather than left to be rediscovered in review.
- Non-convergence **fails loudly**. After five lost rounds `set` throws
  rather than returning, because returning would reintroduce the silent
  loss with extra steps. `delete` stays silent, per the interface contract.

`serialize` is now keyed on the resolved path **process-wide** rather than
per instance. Two `FileSecretStore`s on one file are ordinary — the resolved
store holds one and `absorbFileSecretsIntoKeyring` builds another — and a
per-instance queue does not order them against each other at all. The new
convergence test caught this on its first run: two instances racing `set`
lost an entry. In-process is now correct by construction; the verify covers
what it cannot see, a second Inspector process.

The hand-off's read → copy → delete was the lock's other user. Its delete is
now conditional: the file is re-read immediately before removal and compared
against what was copied, and it declines to delete when they differ. Leaving
the file costs nothing (keychain-wins makes the next run idempotent) while
deleting on a stale read costs a secret.

## Round 9 (two inline, two suppressed)

- **`KeyringSecretStore.getStrict` let raw binding errors escape.** Both
  plaintext migrations keep their source file only when they catch
  `SecretStoreUnavailableError`, so a transient keychain failure 500'd
  `GET /api/servers` instead of abandoning the migration. Wrapped, as `set`
  already does.
- **`FileSecretStore` had no strict read at all**, so `secretStoreGetStrict`
  fell back to the tolerant `get` and mapped every read failure to `null` —
  leaving the exact inversion the seam was introduced to prevent open on the
  store it was introduced for.
- **`readOnDiskEncryption` reported "encrypted" for an envelope it cannot
  open.** Naming the cipher is not the same as being openable: a file with no
  `kdf`/`data`, or a `data` that is not `iv.tag.ciphertext`, produced the
  quiet reassuring footer for a file whose next save was guaranteed to fail.
- **`/api/config` kept a stale descriptor when the resolver returned
  `undefined`**, because the conditional spread left the startup value
  standing. Assigned unconditionally now; `c.json` omits the key.

Full `npm run ci` green.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 18:05:26 -04:00
commit 339aea3729

View file

@ -49,7 +49,6 @@ import {
secretStorageSummary,
} from "../secret-storage-info.js";
import {
acquireLock,
FileSecretStore,
readSecretFilePermissions,
tightenSecretFilePermissions,
@ -481,30 +480,27 @@ export async function absorbFileSecretsIntoKeyring(
if (!fsSync.existsSync(filePath)) return;
const file = new FileSecretStore({ filePath });
// The whole read → copy → delete sequence runs under the same cross-process
// lock every mutation takes. Without it, another Inspector completing a
// `set` between our read and our delete has its brand-new secret removed
// and never copied — a write that reported success and then vanished,
// which is precisely the loss the lock exists to prevent, arrived at
// through the migration meant to preserve things.
let release: (() => Promise<void>) | undefined;
try {
release = await acquireLock(filePath);
} catch (err) {
console.warn(
`\n[mcp-inspector] Could not lock the secrets file at ${filePath} to move it into the OS keychain, so it has been left in place: ${err instanceof Error ? err.message : String(err)}`,
);
return;
}
try {
await handOffUnderLock(file, filePath, keyring);
} finally {
await release();
}
await handOff(file, filePath, keyring);
}
/** The hand-off body. Split out so the lock's release is unmissable. */
async function handOffUnderLock(
/**
* The hand-off body: read the file, copy every entry into the keychain,
* then delete the file.
*
* **The delete is conditional, because there is no lock.** The store's
* mutations are optimistic (see `FileSecretStore.mutate`), so another
* Inspector can complete a `set` between our read and our delete — and
* removing the file then discards a secret whose write reported success,
* which is the exact loss this migration exists to prevent. So the
* contents are re-read immediately before the delete and compared against
* what was copied; if they differ, the new entries are copied too and the
* comparison is made again.
*
* Bounded, and it declines to delete rather than looping forever: leaving
* the file in place costs nothing (the keychain-wins rule makes the next
* run idempotent) while deleting it on a stale comparison costs a secret.
*/
async function handOff(
file: FileSecretStore,
filePath: string,
keyring: SecretStore,
@ -562,6 +558,33 @@ async function handOffUnderLock(
return;
}
// Re-read immediately before deleting. Everything above is now in the
// keychain, but the file is written without a lock, so another Inspector
// may have added an entry since — and that entry has not been copied.
// Deleting on a stale read is how a `set` that reported success loses its
// value.
let latest: Record<string, string> | null;
try {
latest = await file.readAll();
} catch {
// Unreadable now, readable a moment ago: something changed it in a way
// this passphrase cannot open. Leave it — the copies are safe in the
// keychain and the file is the only record of whatever arrived.
console.warn(
`\n[mcp-inspector] Secrets from ${filePath} were copied into the OS keychain, but the file changed while that ran and can no longer be read, so it has been left in place.`,
);
return;
}
if (latest && !sameSecrets(entries, latest)) {
// Leave it for the next run rather than looping here: leaving the file
// costs nothing (keychain-wins makes the retry idempotent) while
// deleting it on a stale comparison costs a secret.
console.warn(
`\n[mcp-inspector] Secrets from ${filePath} were copied into the OS keychain, but the file changed while that ran, so it has been left in place and will be moved on the next run.`,
);
return;
}
try {
await fs.rm(filePath, { force: true });
console.warn(
@ -574,6 +597,18 @@ async function handOffUnderLock(
}
}
/** Do two secret maps hold exactly the same entries? */
function sameSecrets(
a: Record<string, string>,
b: Record<string, string>,
): boolean {
const ak = Object.keys(a);
if (ak.length !== Object.keys(b).length) return false;
return ak.every(
(k) => Object.prototype.hasOwnProperty.call(b, k) && a[k] === b[k],
);
}
/**
* The active store's descriptor — for the banner and `GET /api/config`.
*