fix(auth): refresh in finally, bound the KDF parameters, cover the last two writers

Round 22. Three findings, all in code I wrote, and the first two are both
cases of a stated rationale being wrong rather than a line being wrong.

**`refreshingPersist` skipped the refresh on failure, and I had argued for
that explicitly.** The doc and its test both said "a persist that threw did
not write, so there is nothing new to describe". That is false for this write
order: both persistence paths write the secret store *before* the file, so a
rejected disk write can follow a `set` that has already upgraded
`secrets.json` from plaintext to encrypted. The wrapper then left the footer
describing a file that no longer exists in that form — the stale-descriptor
bug it exists to prevent, reached through its own error path.

It refreshes in a `finally` now. The asymmetry is what settles it: refreshing
after a failure that changed nothing costs one idempotent GET, while not
refreshing after a failure that changed something leaves a security statement
wrong until reload. The test asserting the old behavior is inverted, with the
reasoning recorded so it is not "corrected" back.

**The KDF parameters were validated for well-formedness, not for cost.**
scrypt's work is proportional to `N * r * p`, and all three come off disk — so
a file declaring a modest `N` with `p: 1_000_000` burns unbounded CPU on every
read *and* on merely describing the store for `/api/config`, which is a page
load. Anyone who can write that file can already read its secrets, so this is
not a privilege boundary; it is a footgun and a hang, and the fix is free
because this build writes exactly the constants it now enforces as maxima.

**Two secret-store mutations still bypassed the refresh.** The pagination
toggle resends the server's rehydrated secrets, and deletion sweeps them —
both are writes, and a write is what performs the pending upgrade. Routed
through the same wrapper as the rest.

Full `npm run ci` green, staged tree verified first.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 22:46:42 -04:00
commit 4ce0643ac1

View file

@ -3625,7 +3625,14 @@ function App() {
runCommandInBackground(() => refreshResources(), "ambient");
}
}
void updateServerSettings(activeServerId, next).catch((err: unknown) => {
// Refreshed like every other secret-store mutation: this resends the
// server's rehydrated secrets, so it can trigger the pending
// plaintext-to-encrypted upgrade even though the user only toggled
// pagination (#1950 review r22).
void refreshingPersist(updateServerSettings, refreshInitialConfig)(
activeServerId,
next,
).catch((err: unknown) => {
// Persist failed: revert the optimistic override (the effect only
// clears it when the persisted value changes, which won't happen here)
// and roll the live client setting back, so the UI and client reflect
@ -3892,7 +3899,10 @@ function App() {
setStderrLogState(null);
setActiveServerId(undefined);
}
await removeServer(id);
// Deleting sweeps the server's secrets from the store, which for a
// file-backed store is a write — and a write is what performs the pending
// plaintext-to-encrypted upgrade (#1950 review r22).
await refreshingPersist(removeServer, refreshInitialConfig)(id);
setRemoveTarget(null);
}, [
removeTarget,