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:
cliffhall 2026-08-22 18:33:59 -04:00
commit 219765efff

View file

@ -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>;
};