fix(auth): address Copilot review round 10, and document the concurrency design
The headline is a correction to my own claim rather than a new defect. **The optimistic verify is weaker than the comment said it was.** It described the residual as a crash between the write and the verifying read. The reviewer pointed out that no crash is needed: order two writers as write-A, verify-A, write-B, verify-B and both report success while A's entry is gone — A's verify simply ran before there was anything to see, and B did nothing wrong. A crash is one instance of that shape, not the whole of it. The trace in the old comment quietly assumed A verifies *after* B writes, which nothing guarantees. Corrected in the doc comment, the README and the spec, and the honest summary is now stated rather than implied: this converges in every interleaving where the clobber lands before the verify, it is not mutual exclusion, and a deployment that needs the guarantee wants an OS-backed lock from a dedicated library — not another hand-rolled election. It remains better than what it replaced, which lost updates across a wider set of interleavings and could not be closed at all with what Node exposes. The other findings: - **An envelope naming the cipher still is not an openable one.** Round 9 added a `kdf`/`data` presence check; three dot-separated strings satisfied it while an empty tag, a 4-byte IV or `N: 3` still made every decrypt throw — so the quiet "Encrypted file" footer went to a file whose next save was guaranteed to fail. Now validates part sizes, a non-empty ciphertext, and the scrypt cost parameters (node rejects a non-power-of-two `N` synchronously, out of the derivation rather than the decrypt). A positive control asserts a file we actually wrote still reads as encrypted. - **`encryptionUnknown` + `looseMode` claimed an exposure it had not established.** With both set, `plaintext` is absent, so a `!== false` test landed on "anyone who can read it can read the secrets in it" — about a file that may well hold ciphertext we simply could not classify. Three-way now, in `secretStorageCaveat` and mirrored in the footer's tooltip, with a test on each. - **The README described a lock that no longer exists**, which is the worst kind of stale doc: it stated a durability guarantee the code had stopped providing. Rewritten to describe the actual design, including the residual. - **The private-method access in the concurrency test** now carries the justification the repo's TypeScript rules require: the asserted shape is `writeMap`'s real signature, so a rename breaks the line instead of silently detaching the stub, and a production injection seam would let the real write path drift from the tested one. Also updates `specification/v2_servers_file.md`, which still described #1356's world: one keychain-only store, and unavailability as a hard error. It now covers store selection and the probe, the file format and lazy encryption upgrade, the concurrency design and its residual, the `getStrict`/`getMany` seams and the two traps `getStrict` hit, the durability gate on migration, the keychain hand-off and its conditional delete, and how the active store is surfaced. Full `npm run ci` green. (One unrelated `AppRenderer` theme-flip test failed once under full-suite parallelism and passes in isolation with and without these changes — a flake, not a regression.) Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
339aea3729
commit
219765efff
4 changed files with 267 additions and 72 deletions
|
|
@ -106,6 +106,36 @@ describe("SecretStorageFooter", () => {
|
|||
expect(band).toHaveAttribute("data-tone", "warn");
|
||||
});
|
||||
|
||||
it("does not claim exposure in the tooltip when encryption is unknown", async () => {
|
||||
// Mirrors `secretStorageCaveat`: `plaintext` is absent alongside
|
||||
// `encryptionUnknown`, and a two-way `!== false` test would assert that
|
||||
// a reader gets the secrets out of a file we could not even classify.
|
||||
// The claim lives in the *tooltip*, so that is what this hovers to read
|
||||
// — the visible line reports the unreadable envelope, which is a
|
||||
// different (and also correct) statement.
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<SecretStorageFooter
|
||||
info={{
|
||||
...plaintextFile,
|
||||
plaintext: undefined,
|
||||
looseMode: 0o644,
|
||||
encryptionUnknown: "not valid JSON",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const band = screen.getByTestId("secret-storage-footer");
|
||||
expect(band).toHaveAttribute("data-tone", "warn");
|
||||
|
||||
// The tooltip's target is the copy button, not the band around it.
|
||||
await user.hover(screen.getByRole("button", { name: /Copy secrets file/ }));
|
||||
const tip = await screen.findByText(/Mode 0644 could not be tightened/);
|
||||
expect(tip).toHaveTextContent("could not determine");
|
||||
expect(tip).not.toHaveTextContent(
|
||||
"anyone who can read the file can read the secrets in it",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders nothing when the backend didn't report a store", () => {
|
||||
// A guessed answer under a secret field is worse than no answer, so the
|
||||
// unknown case is silence rather than a default.
|
||||
|
|
|
|||
|
|
@ -197,9 +197,16 @@ function footerTooltip(info: SecretStorageInfo): string | undefined {
|
|||
// secrets. Claiming otherwise overstates the exposure, and a warning
|
||||
// that cries wolf is discounted on the occasion it is literally true.
|
||||
parts.push(
|
||||
info.plaintext === false
|
||||
? `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — others can copy the file, and the passphrase is then the only thing protecting its contents.`
|
||||
: `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — anyone who can read the file can read the secrets in it.`,
|
||||
// Three cases, mirroring `secretStorageCaveat`. With
|
||||
// `encryptionUnknown` set, `plaintext` is absent and `!== false` would
|
||||
// land on the "anyone can read the secrets" wording — asserting an
|
||||
// exposure we did not establish, since the envelope may hold
|
||||
// ciphertext we simply could not classify.
|
||||
info.encryptionUnknown !== undefined
|
||||
? `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — others can copy the file, and whether that exposes the secrets depends on encryption this build could not determine (${info.encryptionUnknown}).`
|
||||
: info.plaintext === false
|
||||
? `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — others can copy the file, and the passphrase is then the only thing protecting its contents.`
|
||||
: `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — anyone who can read the file can read the secrets in it.`,
|
||||
);
|
||||
} else if (info.permissionsUnknown !== undefined) {
|
||||
parts.push(
|
||||
|
|
|
|||
|
|
@ -113,6 +113,27 @@ describe("secretStorageCaveat", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("does not claim exposure when the encryption state is unknown", () => {
|
||||
// `looseMode` with `encryptionUnknown` leaves `plaintext` absent, and a
|
||||
// `!== false` test then lands on the plaintext wording — asserting that
|
||||
// a reader gets the secrets. The envelope may hold ciphertext; we could
|
||||
// not classify it. Claiming the worse of two unknowns is still claiming
|
||||
// something we never established.
|
||||
const loose: SecretStorageInfo = {
|
||||
kind: "file",
|
||||
reason: "fallback",
|
||||
durable: true,
|
||||
looseMode: 0o644,
|
||||
encryptionUnknown: "not valid JSON",
|
||||
path: "/tmp/secrets.json",
|
||||
};
|
||||
const caveat = secretStorageCaveat(loose);
|
||||
expect(secretStorageTone(loose)).toBe("warn");
|
||||
expect(caveat).toContain("0644");
|
||||
expect(caveat).toContain("could not determine");
|
||||
expect(caveat).not.toContain("anyone who can read it can read the secrets");
|
||||
});
|
||||
|
||||
it("warns about a loose mode even on an encrypted file", () => {
|
||||
// The passphrase is then the only thing between a reader and the values,
|
||||
// which is worth saying rather than rendering the quiet neutral state.
|
||||
|
|
|
|||
|
|
@ -936,6 +936,50 @@ describe("readOnDiskEncryption rejects an envelope it could not open", () => {
|
|||
},
|
||||
"encrypted payload is not iv.tag.ciphertext",
|
||||
],
|
||||
// Three dot-separated strings are not enough: each of these satisfies a
|
||||
// part-count check and still makes every decrypt throw, so reporting
|
||||
// "encrypted" would hand the quiet footer to a file whose next save is
|
||||
// guaranteed to fail.
|
||||
[
|
||||
"an initialization vector of the wrong length",
|
||||
{
|
||||
version: 1,
|
||||
encryption: "aes-256-gcm",
|
||||
kdf: { algorithm: "scrypt", salt: "AAAA", N: 16384, r: 8, p: 1 },
|
||||
data: `${Buffer.alloc(4).toString("base64")}.${Buffer.alloc(16).toString("base64")}.${Buffer.alloc(8).toString("base64")}`,
|
||||
},
|
||||
"initialization vector is 4 bytes, expected 12",
|
||||
],
|
||||
[
|
||||
"an authentication tag of the wrong length",
|
||||
{
|
||||
version: 1,
|
||||
encryption: "aes-256-gcm",
|
||||
kdf: { algorithm: "scrypt", salt: "AAAA", N: 16384, r: 8, p: 1 },
|
||||
data: `${Buffer.alloc(12).toString("base64")}.${Buffer.alloc(3).toString("base64")}.${Buffer.alloc(8).toString("base64")}`,
|
||||
},
|
||||
"authentication tag is 3 bytes, expected 16",
|
||||
],
|
||||
[
|
||||
"a KDF cost parameter node would reject",
|
||||
{
|
||||
version: 1,
|
||||
encryption: "aes-256-gcm",
|
||||
kdf: { algorithm: "scrypt", salt: "AAAA", N: 3, r: 8, p: 1 },
|
||||
data: `${Buffer.alloc(12).toString("base64")}.${Buffer.alloc(16).toString("base64")}.${Buffer.alloc(8).toString("base64")}`,
|
||||
},
|
||||
"kdf N is 3, which is not a power of two above 1",
|
||||
],
|
||||
[
|
||||
"an unsupported KDF",
|
||||
{
|
||||
version: 1,
|
||||
encryption: "aes-256-gcm",
|
||||
kdf: { algorithm: "pbkdf2", salt: "AAAA", N: 16384, r: 8, p: 1 },
|
||||
data: `${Buffer.alloc(12).toString("base64")}.${Buffer.alloc(16).toString("base64")}.${Buffer.alloc(8).toString("base64")}`,
|
||||
},
|
||||
'unsupported kdf "pbkdf2"',
|
||||
],
|
||||
])("reports unreadable for %s", async (_label, envelope, detail) => {
|
||||
await fs.writeFile(filePath(), JSON.stringify(envelope), "utf-8");
|
||||
const store = new FileSecretStore({ filePath: filePath() });
|
||||
|
|
@ -946,6 +990,19 @@ describe("readOnDiskEncryption rejects an envelope it could not open", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("readOnDiskEncryption accepts a real envelope", () => {
|
||||
it("still reports encrypted for a file this build actually wrote", async () => {
|
||||
// The negative cases above are only meaningful next to this: tightening
|
||||
// the validation must not start rejecting the files we produce.
|
||||
const store = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "hunter2",
|
||||
});
|
||||
await store.set("srv", "env:A", "1");
|
||||
expect(await store.readOnDiskEncryption()).toEqual({ state: "encrypted" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("concurrent writers (no lock, optimistic verify-and-retry)", () => {
|
||||
// The lock these replace was removed after three review rounds each found
|
||||
// a real race in it; the last needed a compare-and-swap on a directory
|
||||
|
|
@ -1017,6 +1074,15 @@ describe("concurrent writers (no lock, optimistic verify-and-retry)", () => {
|
|||
// queue and writing through one from inside a queued section would
|
||||
// deadlock on it.
|
||||
const clobberAfterEveryWrite = (store: FileSecretStore): void => {
|
||||
// Double cast, justified per the repo's TypeScript rules. The property
|
||||
// reached is `private`, so no single cast expresses it. What makes it
|
||||
// safe is that the asserted shape is `writeMap`'s real signature: the
|
||||
// replacement is checked against it, so a rename or a signature change
|
||||
// fails this line rather than silently leaving the stub detached and the
|
||||
// test passing for the wrong reason. The alternative — a production
|
||||
// injection seam — would add API surface existing only for this test,
|
||||
// and would let the real write path drift from the tested one, which is
|
||||
// precisely what this test exists to catch.
|
||||
const inner = store as unknown as {
|
||||
writeMap: (m: Record<string, string>) => Promise<void>;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue