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

@ -57,6 +57,7 @@ import {
KeyringSecretStore,
parseAccount,
probeKeyringAvailable,
secretStoreGetStrict,
secretStoreIsDurable,
SessionSecretStore,
type SecretStore,
@ -334,7 +335,14 @@ async function describeFileStore(
detail?: string,
): Promise<SecretStorageInfo> {
const onDisk = await store.readOnDiskEncryption();
const plaintext = onDisk === null ? !store.encrypted : onDisk === "none";
// `absent` is the only state where falling back to the configured write
// policy is honest — the first write will create the file in exactly that
// mode. `unreadable` must not: the encryption state was never established,
// and reporting the policy there would claim "Encrypted file" about a file
// `set` is about to refuse.
const unreadable = onDisk.state === "unreadable" ? onDisk.detail : undefined;
const plaintext =
onDisk.state === "absent" ? !store.encrypted : onDisk.state === "plaintext";
// Re-checked here rather than captured at selection, for the same reason
// the encryption state is: the mode can change under a running process,
// and this descriptor is what the browser renders as fact. The *read-only*
@ -346,12 +354,17 @@ async function describeFileStore(
kind,
reason,
path: filePath,
plaintext,
// Omitted entirely when the envelope could not be read, so no consumer
// can read a default out of it.
...(unreadable === undefined ? { plaintext } : {}),
...(unreadable !== undefined ? { encryptionUnknown: unreadable } : {}),
...(perms.state === "loose" ? { looseMode: perms.mode } : {}),
...(perms.state === "unknown" ? { permissionsUnknown: perms.detail } : {}),
// Only meaningful while the two disagree; omitted otherwise so the
// payload does not carry a flag that says nothing.
...(plaintext && store.encrypted ? { pendingEncryption: true } : {}),
...(unreadable === undefined && plaintext && store.encrypted
? { pendingEncryption: true }
: {}),
durable: true,
detail,
};
@ -481,7 +494,17 @@ export async function absorbFileSecretsIntoKeyring(
for (const [account, value] of Object.entries(entries)) {
const parsed = parseAccount(account);
if (!parsed) continue;
const existing = await keyring.get(parsed.serverId, parsed.field);
// Strict: `get` answers `null` for an unreadable keychain as well as
// for a missing entry, and this branch *writes* on `null`. A transient
// read failure would therefore overwrite a newer keychain value with
// the older file copy — the exact inversion of the keychain-wins rule
// this migration is built on. A throw here aborts the hand-off and
// leaves the file in place, so the next run retries.
const existing = await secretStoreGetStrict(
keyring,
parsed.serverId,
parsed.field,
);
if (existing === null) {
await keyring.set(parsed.serverId, parsed.field, value);
}