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
|
|
@ -456,7 +456,7 @@ async function canSteal(
|
|||
} catch {
|
||||
// No stamp to consult; fall through to the time-only answer.
|
||||
}
|
||||
if (!owner || owner.host !== os.hostname() || typeof owner.pid !== "number") {
|
||||
if (!owner || owner.host !== os.hostname() || owner.pid === undefined) {
|
||||
return true;
|
||||
}
|
||||
return !processIsAlive(owner.pid);
|
||||
|
|
@ -475,7 +475,8 @@ function processIsAlive(pid: number): boolean {
|
|||
|
||||
interface LockOwner {
|
||||
token: string;
|
||||
pid: number;
|
||||
/** Absent when the stamp carried no usable (positive) pid. */
|
||||
pid?: number;
|
||||
host: string;
|
||||
}
|
||||
|
||||
|
|
@ -487,8 +488,13 @@ function parseOwner(raw: string): LockOwner | null {
|
|||
const { token, pid, host } = parsed as Partial<LockOwner>;
|
||||
if (typeof token !== "string") return null;
|
||||
return {
|
||||
// Only a *positive* pid is usable. `-1` and `0` are wildcards to
|
||||
// `process.kill` — `kill(-1, 0)` asks about every process this user
|
||||
// can signal and answers "alive" for almost any host, which would make
|
||||
// a garbled stamp permanently unstealable. Anything else is recorded
|
||||
// as absent so the caller falls back to the mtime.
|
||||
pid: typeof pid === "number" && pid > 0 ? pid : undefined,
|
||||
token,
|
||||
pid: typeof pid === "number" ? pid : -1,
|
||||
host: String(host),
|
||||
};
|
||||
} catch {
|
||||
|
|
@ -496,6 +502,17 @@ function parseOwner(raw: string): LockOwner | null {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the secrets file's envelope says, or why it could not be read.
|
||||
* `absent` is "nothing written yet"; `unreadable` is "there is a file and
|
||||
* we cannot tell" — two very different things to say to a user.
|
||||
*/
|
||||
export type SecretFileEncryptionState =
|
||||
| { state: "absent" }
|
||||
| { state: "plaintext" }
|
||||
| { state: "encrypted" }
|
||||
| { state: "unreadable"; detail: string };
|
||||
|
||||
export interface FileSecretStoreOptions {
|
||||
/** Absolute path of the secrets file. */
|
||||
filePath: string;
|
||||
|
|
@ -550,20 +567,36 @@ export class FileSecretStore implements SecretStore {
|
|||
* Reads the envelope only. It never decrypts, so it needs no passphrase
|
||||
* and answers correctly even when the key is wrong or missing — which is
|
||||
* also a state the UI has to describe rather than throw on.
|
||||
*
|
||||
* `absent` and `unreadable` are kept apart deliberately. Collapsing them
|
||||
* (as a bare `null` did) means an existing file that is corrupt, or
|
||||
* written in an envelope this build does not know, is reported as "no
|
||||
* file yet" — and the descriptor then falls back to the configured write
|
||||
* policy, so a passphrase-configured install claims "Encrypted file"
|
||||
* about a file whose encryption state was never established and which
|
||||
* `set` is in fact about to refuse.
|
||||
*/
|
||||
async readOnDiskEncryption(): Promise<SecretFile["encryption"] | null> {
|
||||
async readOnDiskEncryption(): Promise<SecretFileEncryptionState> {
|
||||
let raw: string | null;
|
||||
try {
|
||||
raw = await readStoreFile(this.filePath);
|
||||
} catch (err) {
|
||||
return {
|
||||
state: "unreadable",
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
if (raw === null) return { state: "absent" };
|
||||
try {
|
||||
const raw = await readStoreFile(this.filePath);
|
||||
if (raw === null) return null;
|
||||
const parsed = JSON.parse(raw) as SecretFile;
|
||||
return parsed.encryption === "none" || parsed.encryption === CIPHER
|
||||
? parsed.encryption
|
||||
: null;
|
||||
if (parsed.encryption === "none") return { state: "plaintext" };
|
||||
if (parsed.encryption === CIPHER) return { state: "encrypted" };
|
||||
return {
|
||||
state: "unreadable",
|
||||
detail: `unsupported envelope (encryption="${String(parsed.encryption)}", version=${String(parsed.version)})`,
|
||||
};
|
||||
} catch {
|
||||
// Absent, unreadable, or not JSON. "Unknown" is the honest answer, and
|
||||
// the caller falls back to reporting the configured policy — the only
|
||||
// thing that is knowable about a file nobody can read.
|
||||
return null;
|
||||
return { state: "unreadable", detail: "not valid JSON" };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue