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:
parent
1aa1f5a0ca
commit
fdeffa1f00
1 changed files with 489 additions and 29 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue