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:
parent
37faf1be88
commit
339aea3729
1 changed files with 528 additions and 751 deletions
|
|
@ -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`.
|
||||
*
|
||||
|
|
|
|||
Loading…
Reference in a new issue