fix(auth): address Copilot review round 6 on the secret-store PR

Four findings, all suppressed, all real. Three are the same mistake in
different places: a state that was never established being reported as a
confident answer.

- **`readOnDiskEncryption` conflated "no file" with "unreadable file".**
  Both answered `null`, and the descriptor falls back to the configured
  write policy on `null` — so a passphrase-configured install reported
  "File (encrypted)" about a file whose envelope had never parsed, and
  which `set` was in fact about to refuse rather than overwrite. It now
  returns a four-state result, and only `absent` falls back to the policy
  (honestly: the first write really will create it that way). `unreadable`
  produces `encryptionUnknown` on the descriptor, `plaintext` is omitted
  entirely so nothing can read a default out of it, and the label, caveat,
  tone and footer all carry "unreadable" rather than guessing a mode.

- **The loose-mode warning overstated exposure for an encrypted file.**
  "Anyone who can read it can read the secrets in it" is false when the
  file is ciphertext — a reader gets the bytes and still needs the
  passphrase. Both the caveat and the footer tooltip now branch on
  encryption: the passphrase becomes the only protection, which is the
  real consequence. Overstating it is not harmless, because a warning that
  cries wolf is discounted on the occasion it is literally true.

- **The keychain hand-off could invert its own keychain-wins rule.**
  `SecretStore.get` answers `null` both for a missing entry and for a
  keychain it could not read, and the migration *writes* on `null` — so a
  transient read failure would overwrite a newer keychain value with the
  older file copy. Added an optional `getStrict` (throws instead of
  swallowing) implemented by `KeyringSecretStore`, with
  `secretStoreGetStrict` falling back to `get` for stores whose reads
  cannot fail. A throw now aborts the hand-off with the file left in
  place, so the next run retries.

Writing the tests turned up one more, in code the review had passed:
`parseOwner` recorded a garbled pid as `-1`, and `process.kill(-1, 0)` is
a *wildcard* asking about every process this user can signal — it answers
"alive" almost always, which would have made a lock with a corrupt stamp
permanently unstealable. Only a positive pid is usable now; anything else
is treated as no pid at all and the mtime decides.

Tests: the four envelope states kept distinct; the descriptor refusing to
guess, with the genuine no-file case as the control; strict-read abort
without a write and with the file preserved, plus a store whose `get` and
`getStrict` disagree so the test proves which one the migration consults;
encryption-aware wording asserted in both directions in the caveat and the
band; three unusable owner stamps and a wildcard pid; and the descriptor
shapes that must now be rejected — neither half of the encryption question
answered, or both. Full `npm run ci` green — 5,750 tests under the
coverage gate.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 14:40:29 -04:00
commit fdeffa1f00

View file

@ -320,6 +320,23 @@ export interface SecretStore {
*/
isDurable?(): Promise<boolean>;
get(serverId: string, field: string): Promise<string | null>;
/**
* Like {@link get}, but **throws** when the store cannot be read instead
* of answering `null`.
*
* `get` is deliberately tolerant — an unreachable keychain and an absent
* entry are the same answer to a caller that just wants to render a form.
* That tolerance is wrong for exactly one caller: a migration that treats
* `null` as proof of absence and then *writes*. A transient read failure
* would look like "nothing there", and the write would overwrite a newer
* keychain value with an older file copy — silently inverting the
* keychain-wins rule the migration exists to honor.
*
* Optional, so existing implementations (and test doubles) keep
* compiling; {@link secretStoreGetStrict} falls back to `get` when it is
* absent, which is correct for stores whose reads cannot fail.
*/
getStrict?(serverId: string, field: string): Promise<string | null>;
set(serverId: string, field: string, value: string): Promise<void>;
/** No-op if no entry exists. */
delete(serverId: string, field: string): Promise<void>;
@ -370,6 +387,21 @@ export interface SecretStore {
* one the first version of this contract actually handled.
*/
export class KeyringSecretStore implements SecretStore {
/**
* The intolerant read. Same lookup as {@link get}, minus the catch — a
* keychain that cannot answer must not be reported as an empty one to a
* caller that is about to write based on the answer.
*/
async getStrict(serverId: string, field: string): Promise<string | null> {
const keyring = await loadKeyring();
if (!keyring.ok) throw new KeychainUnavailableError(keyring.err);
const entry = new keyring.mod.AsyncEntry(
SERVICE_NAME,
buildAccount(serverId, field),
);
return (await entry.getPassword()) ?? null;
}
async get(serverId: string, field: string): Promise<string | null> {
try {
const keyring = await loadKeyring();
@ -466,6 +498,22 @@ export class KeyringSecretStore implements SecretStore {
* other way would make every double silently non-durable and quietly
* disable the migrations they exist to exercise.
*/
/**
* Read `store` intolerantly: throw rather than answer `null` when the store
* itself cannot be read. Falls back to `get` for a store that does not
* distinguish the two, which is right for the in-memory and file stores —
* neither can fail in a way that masquerades as absence.
*/
export async function secretStoreGetStrict(
store: SecretStore,
serverId: string,
field: string,
): Promise<string | null> {
return store.getStrict
? store.getStrict(serverId, field)
: store.get(serverId, field);
}
export async function secretStoreIsDurable(
store: SecretStore,
): Promise<boolean> {