Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 40 additions & 7 deletions packages/extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand Up @@ -1633,22 +1639,41 @@ 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,
restBaseUrl: active.conn.restBaseUrl,
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.
Expand Down Expand Up @@ -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<string, unknown> };
// 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;
}
Expand Down
Binary file added packages/extension/src/consent-replay.ts
Binary file not shown.
126 changes: 126 additions & 0 deletions packages/extension/tests/consent-replay.test.mts
Original file line number Diff line number Diff line change
@@ -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));
});
Loading