From f6e4ac8aebeb8d2f16f4da3548c3f9ad822c64fc Mon Sep 17 00:00:00 2001 From: Sterister Date: Tue, 8 Sep 2026 16:42:37 +0200 Subject: [PATCH] test(crypto): stop letting the scheduler answer for the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third flake found in the same hunt. `timing variance stays bounded across mismatch positions` took two single measurements of 50 000 iterations each and compared their ratio, which is one GC pause away from a false alarm — it failed about one run in fourteen on a loaded machine. Two things were wrong with the measurement, not the property: The first loop through that code pays for JIT compilation the second one does not, so whichever side ran first was biased upward. The old comment admitted it — "allow 2x variance for JIT/noise" — which is a tolerance covering a measurement artefact rather than the thing under test. There is a warm-up now. And a single pair has no defence against a scheduler hiccup. Five interleaved pairs compared by median throw the pause out instead of the property. The threshold is unchanged at 3, deliberately. An early-exit compare takes ~256x longer to reach a mismatch in the last byte than the first, on every round; nothing here weakens what the test catches. Verified that it still catches what it is for: with `constantTimeEqual` temporarily replaced by an early-exit loop the test fails, and passes again with the real one restored. A security test nobody has watched fail is a security test nobody knows works. A security test that cries wolf under load is worse than none — it teaches people to re-run until green, and then a real regression looks like the noise. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014489bKUtUEY1Zgs9xN9mt7 --- .../shade-crypto-web/tests/hardening.test.ts | 48 ++++++++++++------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/packages/shade-crypto-web/tests/hardening.test.ts b/packages/shade-crypto-web/tests/hardening.test.ts index 584f342..c308a4c 100644 --- a/packages/shade-crypto-web/tests/hardening.test.ts +++ b/packages/shade-crypto-web/tests/hardening.test.ts @@ -59,26 +59,40 @@ describe('Cryptographic Hardening', () => { const mismatchAtEnd = new Uint8Array(target); mismatchAtEnd[len - 1] ^= 0xff; - // Measure many iterations to get a stable signal - const iterations = 50000; + const iterations = 20000; + const measure = (other: Uint8Array): number => { + const t0 = performance.now(); + for (let i = 0; i < iterations; i++) crypto.constantTimeEqual(target, other); + return performance.now() - t0; + }; - const start1 = performance.now(); - for (let i = 0; i < iterations; i++) { - crypto.constantTimeEqual(target, mismatchAtStart); + // Warm up before measuring anything. The first loop through this code + // pays for JIT compilation that the second one does not, which biased + // whichever side ran first and is why the old version had to "allow 2x + // for JIT/noise" — a tolerance covering a measurement artefact rather + // than the property under test. + measure(mismatchAtStart); + measure(mismatchAtEnd); + + // Paired, interleaved samples, compared by median. A single pair is one + // GC pause away from a false alarm: this test failed roughly one run in + // fourteen on a loaded machine on 08.09.2026, and a security test that + // cries wolf under load is a security test people learn to re-run until + // it passes. The median throws out the pause instead of the property. + const ratios: number[] = []; + for (let round = 0; round < 5; round++) { + const a = measure(mismatchAtStart); + const b = measure(mismatchAtEnd); + ratios.push(Math.max(a, b) / Math.min(a, b)); } - const timeStart = performance.now() - start1; + ratios.sort((x, y) => x - y); + const median = ratios[Math.floor(ratios.length / 2)]!; - const start2 = performance.now(); - for (let i = 0; i < iterations; i++) { - crypto.constantTimeEqual(target, mismatchAtEnd); - } - const timeEnd = performance.now() - start2; - - // With constant-time comparison, these should be very close. - // Non-constant-time would show timeEnd >> timeStart (early exit vs full scan). - // Allow 2x variance for JIT/noise, but it should never be 10x. - const ratio = Math.max(timeStart, timeEnd) / Math.min(timeStart, timeEnd); - expect(ratio).toBeLessThan(3); + // The threshold is unchanged and deliberately so: an early-exit compare + // would take ~256x longer to reach a mismatch in the last byte than the + // first, on every single round. Nothing about sampling weakens what this + // catches — it only stops the scheduler from answering for the code. + expect(median).toBeLessThan(3); }); });