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
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Reference in a new issue