From 3cb39a60337b0a2d777d9dd59f9a777561e9c12f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 19 Oct 2026 13:11:58 +0100 Subject: [PATCH] fix(admission): remember hub proof verdicts per proof, not per device A hub directory event can carry more than one proof for the same device at once. The verdict cache held a single slot per kind and device, so each proof evicted the other and every event verified all of them again, once per trusted issuer. The cache is now keyed by kind, device and proof. --- src/core/directory-admission.ts | 8 +++----- src/test/directory-admission.unit.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/core/directory-admission.ts b/src/core/directory-admission.ts index 4c9e5504..df856526 100644 --- a/src/core/directory-admission.ts +++ b/src/core/directory-admission.ts @@ -41,7 +41,6 @@ export interface DirectoryAdmissionDeps { } interface Verdict { - proof: string; ok: boolean; /** When a refusal stops being remembered (epoch ms). */ until: number; @@ -65,7 +64,7 @@ export function directoryAdmission( deps: Readonly, ): (entries: readonly DirectoryEntry[]) => Promise { const { gatewayTrust, verifyMembership } = deps; - /** Keyed by kind and device, since a device can carry one proof of each kind. */ + /** Keyed by kind, device and proof. A directory event can carry several proofs for one device at once, a stale copy beside a fresh one, so a slot per device would be overwritten by each in turn and every event would verify them all again. */ const verdicts = new Map(); const remember = (key: string, verdict: Readonly): void => { @@ -123,9 +122,9 @@ export function directoryAdmission( const proof = readMembershipProof(entry.advert, field); if (proof === undefined) continue; - const key = `${kind}:${deviceHex}`; + const key = `${kind}:${deviceHex}:${proof}`; const known = verdicts.get(key); - if (known?.proof === proof) { + if (known !== undefined) { if (known.ok && gatewayTrust.isVouchedBy(deviceHex, kind)) { vouched = true; continue; @@ -137,7 +136,6 @@ export function directoryAdmission( const vouch = await vouchedFor(proof, deviceHex, kind); remember(key, { - proof, ok: vouch !== undefined, until: Date.now() + NEGATIVE_VERDICT_TTL_MS, }); diff --git a/src/test/directory-admission.unit.test.ts b/src/test/directory-admission.unit.test.ts index fd06dc4c..27aeaeac 100644 --- a/src/test/directory-admission.unit.test.ts +++ b/src/test/directory-admission.unit.test.ts @@ -146,6 +146,28 @@ describe("directoryAdmission", () => { expect(verifyMembership).toHaveBeenCalledTimes(1); }); + it("does not re-verify either of two proofs for one device that arrive in alternation", async () => { + const trust = new GatewayTrust(); + trust.addPrincipal(PRINCIPAL); + const verifyMembership = verifier(false); + const admit = directoryAdmission({ gatewayTrust: trust, verifyMembership }); + + await admit([ + entry("alternating", "proof-a"), + entry("alternating", "proof-b"), + ]); + await admit([ + entry("alternating", "proof-a"), + entry("alternating", "proof-b"), + ]); + await admit([ + entry("alternating", "proof-b"), + entry("alternating", "proof-a"), + ]); + + expect(verifyMembership).toHaveBeenCalledTimes(2); + }); + it("verifies a device's new proof even while its old one is still good, so its trust is renewed before the old one lapses", async () => { const trust = new GatewayTrust(); trust.addPrincipal(PRINCIPAL);