From fcda3b623b223c73afec68fefff1154c9324aa99 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Mon, 31 Aug 2026 17:06:07 +0200 Subject: [PATCH] fix: stop asking a third time to publish a change already approved twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A consent-gated DID edit prompted the operator three times: the worker-mode confirm, the approval ceremony on their device, and then the *same* worker-mode confirm again when the page replayed its pinned re-submit. The third prompt asks nothing new. By the time it fires the human has approved that exact payload twice — once here, once on the approving device, which showed the VTA's dry-run effects and a match code to compare — and the VTA is holding a single-use grant bound to the payload's digest. It is the same question, asked again on the weaker of the two surfaces. That is not free. The VTA's own `policy_gate.rs` states the cost: consent designs die to habituation long before they die to cryptography. Three prompts an edit, one of them uninformative, is how a person is trained to click through the one that matters. The gate itself is unchanged and still un-skippable — its reason holds, and is unrelated: with `policy.enforcement` off it is the only thing between an arbitrary page and an arbitrary task. What changes is that one replay is recognised as the completion of a ceremony rather than a new request. The exemption requires all three of: the same origin and byte-identical params; a prior `consentRequired` refusal for them, so only a task that did *not* run is ever tracked; and a `task-consent/granted` since relayed, matched on the VTA's own `payloadDigest`. It is single-use and expires with the grant it depends on (600 s, the VTA's `GRANT_TTL_SECS`). The digest is never recomputed here. It comes off the wire twice — from the VTA's refusal and from its granted notice — so this adds no second implementation of a consensus-critical hash to drift out of step. That mattered: the digest construction is domain-tagged, length-prefixed and JCS-canonical, and a divergent copy would fail as a mismatch nobody sees. Every failure mode falls back to prompting. A key that does not match, a grant that never arrives, a page that re-serialises its payload between submits, a service-worker restart that empties the in-memory ledger — all show the confirm again, which is the direction a mistake should fail in. The state machine is a pure module so it can be tested without `chrome`, following `sites-model.ts`. Eleven tests, and most of them are about what must *not* be exempt: another payload from the same origin, the same payload from another origin, a grant for an unrelated digest, a grant that arrives before any refusal, a re-refused entry, and anything past the TTL. Signed-off-by: Glenn Gore --- packages/extension/src/background.ts | 47 ++++++- packages/extension/src/consent-replay.ts | Bin 0 -> 6084 bytes .../extension/tests/consent-replay.test.mts | 126 ++++++++++++++++++ 3 files changed, 166 insertions(+), 7 deletions(-) create mode 100644 packages/extension/src/consent-replay.ts create mode 100644 packages/extension/tests/consent-replay.test.mts diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 437ee0a..8e3bea8 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -193,6 +193,12 @@ import { displayHostFor, hasOriginPermission, } from "./host-permissions.js"; +import { ConsentReplayLedger, replayKey } from "./consent-replay.js"; + +/** Consent-gated requests awaiting their one exempt replay. In-memory by + * design: a service-worker restart loses it, and losing it costs one extra + * confirm rather than admitting anything. */ +const consentReplays = new ConsentReplayLedger(); // Keep the provider registration in step with the grants. // @@ -1633,15 +1639,24 @@ async function handleRequestTask( // mistaken for the approval step, and the WORKER banner on the confirm popup // reinforces it. Kept un-skippable on purpose: with policy enforcement off this // is the only thing between an arbitrary page and an arbitrary task. - const approved = await requestConsent({ - origin: req.origin, - action: `send a "${taskLabel(req.params.type)}" request to your VTA`, - noRemember: true, - }); - if (!approved.approved) return { ok: false, error: "user denied the request" }; + // + // The one exemption is the replay that *completes* a consent ceremony: same + // origin, same params, already refused once with `consentRequired`, and a + // matching grant since relayed. The human approved that exact payload here and + // then again on the approving device — see `consent-replay.ts` for why asking + // a third time costs more than it buys. + const key = replayKey(req.origin, req.params); + if (!consentReplays.consumeIfArmed(key)) { + const approved = await requestConsent({ + origin: req.origin, + action: `send a "${taskLabel(req.params.type)}" request to your VTA`, + noRemember: true, + }); + if (!approved.approved) return { ok: false, error: "user denied the request" }; + } await ensureOffscreenDocument(); - return (await chrome.runtime.sendMessage({ + const res = (await chrome.runtime.sendMessage({ target: OFFSCREEN_TARGET, type: OFFSCREEN_REQUEST_TASK, vtaDid: active.conn.vtaDid, @@ -1649,6 +1664,16 @@ async function handleRequestTask( origin: req.origin, params: req.params, })) as RuntimeRequestTaskResponse; + + // A consent refusal is the only thing that arms a replay, and it carries the + // VTA's own salted digest — the same value its `task-consent/granted` notice + // will quote. Taking it from the wire rather than recomputing it is what keeps + // a second implementation of that hash from existing here to drift. + if (res.ok && res.result?.kind === "consentRequired") { + const digest = res.result.payloadDigest; + if (typeof digest === "string" && digest) consentReplays.recordConsentRequired(key, digest); + } + return res; } // Sign a Trust-Task envelope with the wallet's holder did:peer #key-2. @@ -2492,6 +2517,14 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { // broadcast a wallet event to pages (e.g. `consentgranted`). Fire-and-forget. if ((message as { type?: string })?.type === RUNTIME_EMIT_WALLET_EVENT) { const m = message as { event: WalletEventKind; detail?: Record }; + // A grant landed for a payload the approver signed off. Arm its one exempt + // replay before the page is told, so the re-submit this event triggers does + // not race the ledger. Only the offscreen inbound path emits this, and only + // for a notice it accepted from an enrolled VTA. + if (m.event === "consentgranted") { + const digest = m.detail?.payloadDigest; + if (typeof digest === "string" && digest) consentReplays.recordGranted(digest); + } void broadcastWalletEvent(m.event, m.detail); return false; } diff --git a/packages/extension/src/consent-replay.ts b/packages/extension/src/consent-replay.ts new file mode 100644 index 0000000000000000000000000000000000000000..141ffb007cc6ca22e27eedc07602d36926ea869d GIT binary patch literal 6084 zcmai2?QR>n742_71%oVhB|(ziU7$albpkhQ?552ZY@^$vC?aZxlGu!fDmi0C4Gi=l z`hx!0<1hkUfz#$&b^$|Q}gS}wZ@vxrjL^)Q}Nx;R;f+R+%CS@^g%Ti>lh-x)aQ+oh~r^=m6muY9ac=hx2IIoHt1 zWbtRUvhC7MVTY!kK1G;2gFWt(TW>la_{%3a1b1xH`DS{bV)BW_U zc6V4lM#g-#GHDgIV%3>H-Cll|OdWTV*rQ^Lg{xR)WZasy?Xb84zIcup%$2|{vkFa( zl_tS{<0gG_W*IHm0Az7lfEOrZ_LqnFCCD%}mkcLL;T0@|YBFumYP4>vB}dBS1yd&> znXR)xJQK-0b=!c{#)Yk1Dg`OXfibg)4p_=q_`rluW11PSwiv>uN31XpYI(rn+>CO0 z7;Ej4U$%4`M+LL8Kt^wc3r!HzpwM`=a=Wsoin~o0kcG`^Hyr`S@PMWYCpK{2)gF*0 zr8SH-7~zRiWE&_3L=#fGMPji!!ySn9kPNs%YZb8l5$7J1fYv10umQE%XG3_58ZcW2 zCgXroBABB^aTKdiNhlZiEz0DhtIzWaV}3g|qtPu4iC$z*Pe+W>nUBap1*FEzEf4`| zF1#(kjdl(5NrEu^uXWHWhpMf{R zeC}5E&WAqYNOUG1y6_N-kL>VZMW138**AmP&)1i4Z$IGohnuUHH#2i#es^|e(r{|tTM!CZ z<38QtlNhWWx>Rxz#F->tL)m~ExN;DpD)bEz4{mPl9pS5zhMuAwQ_7OfEdwGWVCIqs zv>Q;U-EHj-3_Cq>pEe;9@h#lBzIy-q@-O1p1-;L^M~yH)!WWlp>?dFMF>8%FgkA8_N?g>kz5nmTaondz}JvKxiMOcfo*R##`+SxU|! z=Am)6E!JhICW4qMq9K_I(;fnW!>|kBWec`DH*L>R@!83})RSd+Rb!)H?BF8NA;r{r zsoAN841M7}!YZ)8LFsJc38rY_04FzKl;VWuM^JaWzi7=ol0Ggm2dIGMfFLF%~dwJHYJ?65A5W$o( ztTJ0V0dcsbap>;OEcMm^_&yL8TGQDtYfG{Vhf@C;kmg_TGlnJh{d0^tA6qoEVR<9ijjrosYi8ek)@}E{`R4N< zZr;6}YLZdXf$ksP&yEXzOX9*gy@2IuIu{;9+gfxP%Eiig%!>eps!n#ZRR2Bu2V2an zg7mE2Y{Hq5I>qLRz@as`%qgFks%H1h%-Ch13K(*y9p*{IB&x|2Yv;m<35gZ5z_#=p zR%`1?s0Zn*8k>@NsZl7eU+XQ9#0CRuQOBpG1r_81rPxBMH$Q5A#>Zmtd@v-R4!gRp zd4t3kx*We++fP?&^ULp1sdFPnR^8D`F^XXZjsGjA$GhQE*~Xt( zKTrpM;!OdM21CgwR7NzG2k(UzS%%0%HRw40P{rA}wkGT{ET^cB4h_JQv_HZ?l}N1# z^%vk(*lOq18ym(WqL24vLbz}l(18oO^V&@~_eD96Yh*0vpc5fkdaXgT#BF=8xm?Am z7gFO>!iNcXg;vd|$}EVMsKXWINP4hJp5i$9Rgxi(j1RtWtJh=YU$0fNyuxoQR8yi(K-dTA5}|APi&gR27Mt zhg$bWZZybAsiKVD{0@m2%QOt7*x$8YNX|5Iq;=2hCo?7P02LSrfrP~C#}2PpA-)!O zbVAyHOFF(es71YY9^D^Utq^#@%U91l{0nl_{Jzl=_s)m|I8+#P+oIcG(8} zuF!^go5UwxioafDWMhJ A_W%F@ literal 0 HcmV?d00001 diff --git a/packages/extension/tests/consent-replay.test.mts b/packages/extension/tests/consent-replay.test.mts new file mode 100644 index 0000000..322a0d6 --- /dev/null +++ b/packages/extension/tests/consent-replay.test.mts @@ -0,0 +1,126 @@ +// The one exempt replay that completes a consent ceremony — see +// src/consent-replay.ts for why it exists. +// +// The property under test is narrow and worth stating: a replay is exempt only +// when it is the *same request*, already refused for consent, and a grant for +// that exact payload has since arrived. Every other path prompts. The tests +// below are mostly about the "every other path" half, because that is the half +// a future edit could quietly widen. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { ConsentReplayLedger, replayKey } from "../src/consent-replay.ts"; + +const ORIGIN = "https://dids.eu.openvtc.net"; +const OTHER_ORIGIN = "https://evil.example"; +const PARAMS = { type: "https://trusttasks.org/spec/vta/webvh/dids/update/1.0", payload: { a: 1 } }; +const OTHER_PARAMS = { ...PARAMS, payload: { a: 2 } }; +const DIGEST = "zQmGrantedDigest"; + +/** A ledger with a clock we control, so TTL is tested without sleeping. */ +function ledgerAt(clock: { now: number }) { + return new ConsentReplayLedger({ now: () => clock.now }); +} + +test("the happy path: refused, granted, then one replay goes through", () => { + const led = new ConsentReplayLedger(); + const key = replayKey(ORIGIN, PARAMS); + + assert.equal(led.consumeIfArmed(key), false, "nothing is exempt before a refusal"); + led.recordConsentRequired(key, DIGEST); + assert.equal(led.consumeIfArmed(key), false, "a refusal alone must not exempt anything"); + led.recordGranted(DIGEST); + assert.equal(led.consumeIfArmed(key), true); +}); + +test("the exemption is single-use", () => { + const led = new ConsentReplayLedger(); + const key = replayKey(ORIGIN, PARAMS); + led.recordConsentRequired(key, DIGEST); + led.recordGranted(DIGEST); + assert.equal(led.consumeIfArmed(key), true); + // The VTA's grant is single-use, so a second replay is a new question. + assert.equal(led.consumeIfArmed(key), false); +}); + +test("a grant does not exempt a different payload from the same origin", () => { + const led = new ConsentReplayLedger(); + led.recordConsentRequired(replayKey(ORIGIN, PARAMS), DIGEST); + led.recordGranted(DIGEST); + assert.equal(led.consumeIfArmed(replayKey(ORIGIN, OTHER_PARAMS)), false); +}); + +test("a grant does not exempt the same payload from a different origin", () => { + const led = new ConsentReplayLedger(); + led.recordConsentRequired(replayKey(ORIGIN, PARAMS), DIGEST); + led.recordGranted(DIGEST); + assert.equal(led.consumeIfArmed(replayKey(OTHER_ORIGIN, PARAMS)), false); +}); + +test("a grant for an unrelated digest arms nothing", () => { + const led = new ConsentReplayLedger(); + const key = replayKey(ORIGIN, PARAMS); + led.recordConsentRequired(key, DIGEST); + led.recordGranted("zQmSomeOtherTaskEntirely"); + assert.equal(led.consumeIfArmed(key), false); +}); + +test("a grant arriving before any refusal arms nothing", () => { + // Ordering matters: only a request the VTA actually refused for consent is + // ever tracked, so a grant cannot pre-authorise a request not yet made. + const led = new ConsentReplayLedger(); + led.recordGranted(DIGEST); + led.recordConsentRequired(replayKey(ORIGIN, PARAMS), DIGEST); + assert.equal(led.consumeIfArmed(replayKey(ORIGIN, PARAMS)), false); +}); + +test("a fresh refusal disarms an entry", () => { + // The VTA re-issues `consentRequired` on every re-submit. A new refusal means + // the question is open again, so a previously-armed entry must not stay armed. + const led = new ConsentReplayLedger(); + const key = replayKey(ORIGIN, PARAMS); + led.recordConsentRequired(key, DIGEST); + led.recordGranted(DIGEST); + led.recordConsentRequired(key, DIGEST); + assert.equal(led.consumeIfArmed(key), false); +}); + +test("an armed replay expires with the grant it depends on", () => { + const clock = { now: 1_000_000 }; + const led = ledgerAt(clock); + const key = replayKey(ORIGIN, PARAMS); + led.recordConsentRequired(key, DIGEST); + led.recordGranted(DIGEST); + // Past the VTA's own 600 s grant TTL the grant is dead server-side, so an + // exemption could only wave through a submit that will be refused anyway. + clock.now += 600_001; + assert.equal(led.consumeIfArmed(key), false); +}); + +test("an armed replay still works just inside the window", () => { + const clock = { now: 1_000_000 }; + const led = ledgerAt(clock); + const key = replayKey(ORIGIN, PARAMS); + led.recordConsentRequired(key, DIGEST); + led.recordGranted(DIGEST); + clock.now += 599_000; + assert.equal(led.consumeIfArmed(key), true); +}); + +test("tracked requests are bounded, oldest evicted first", () => { + const led = new ConsentReplayLedger({ maxEntries: 3 }); + for (let i = 0; i < 5; i++) led.recordConsentRequired(replayKey(ORIGIN, { i }), `d${i}`); + assert.equal(led.size, 3); + // The first two were evicted, so arming their digests exempts nothing. + led.recordGranted("d0"); + assert.equal(led.consumeIfArmed(replayKey(ORIGIN, { i: 0 })), false); + led.recordGranted("d4"); + assert.equal(led.consumeIfArmed(replayKey(ORIGIN, { i: 4 })), true); +}); + +test("replayKey separates origin from params", () => { + // A page must not be able to spoof another origin's key by stuffing the + // separator into its own params. + assert.notEqual(replayKey(ORIGIN, PARAMS), replayKey(OTHER_ORIGIN, PARAMS)); + assert.notEqual(replayKey(ORIGIN, PARAMS), replayKey(ORIGIN, OTHER_PARAMS)); +});