From 1e5cc984ba5f84dafd784c4fadf1630ed8f4f51a Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Tue, 8 Sep 2026 13:26:50 +0200 Subject: [PATCH 1/2] feat(persona): finish a release: stepUp disclosure instead of failing at the last hop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `handleDisclose` returned the agent's `stepUpRequired` refusal to the page as prose, so a `payment.*` or `gov.*` disclosure died after the holder had already approved it on the consent screen — the ceremony four repositories were built for was never driven. This is the same failure `vta/request-task.ts` describes for the consent refusal it handles: the flow discarded at the last hop, where nobody looks. The wallet now recognises the refusal, verifies the agent's approve-request, asks the holder, answers it, and retries `present` with the SAME previewId — the refusal did not consume it, which is what makes this a retry. `runPersonaTask` keeps the structured refusal because the wallet must tell one refusal from another; none of it reaches the page, which still gets prose or the presentation. The prompt calls `requestConsent` directly rather than `gatedConsent`: the latter returns true outright for a remembered origin, which would turn "each time" into "once per site" — the same failure as binding to the session, from a different direction. `noRemember` so it cannot become one. The approve-response is a payload, not a document: it goes as an ordinary Trust Task and the channel's own `assertionMethod` signature IS the gate. Minted 0.3, so a bound approval is answered `recorded` and elevates nothing. Signed-off-by: Glenn Gore --- packages/core/src/persona/step-up.ts | 65 ++++++++++- packages/core/tests/persona.step-up.mjs | 51 +++++++++ packages/extension/src/background.ts | 142 ++++++++++++++++++++++-- 3 files changed, 247 insertions(+), 11 deletions(-) diff --git a/packages/core/src/persona/step-up.ts b/packages/core/src/persona/step-up.ts index 29c9886..7ca05c0 100644 --- a/packages/core/src/persona/step-up.ts +++ b/packages/core/src/persona/step-up.ts @@ -144,15 +144,33 @@ export type VerifyDisclosureStepUpResult = */ export function disclosureStepUpRequiredFrom(e: unknown): DisclosureStepUpRequired | null { if (!(e instanceof VtaClientError)) return null; - const body = e.details as | { code?: unknown; details?: Record } | undefined; - if (body?.code !== DISCLOSURE_STEP_UP_REQUIRED_CODE) return null; + return disclosureStepUpFrom(body?.code, body?.details); +} - const d = body.details ?? {}; +/** + * The same recognition, from a refusal that was **not** thrown. + * + * A wallet that dispatches the task itself gets the agent's `code` and + * `details` as fields rather than inside an exception — the relay shape the + * console and the background use. One rule, two entry points: a second + * implementation would be the same three checks written twice, and the pair + * would drift on the third change rather than the first. + */ +export function disclosureStepUpFrom( + code: unknown, + details: unknown, +): DisclosureStepUpRequired | null { + if (code !== DISCLOSURE_STEP_UP_REQUIRED_CODE) return null; + + const d = (details ?? {}) as Record; const previewId = typeof d.previewId === "string" ? d.previewId : ""; const req = d.approveRequest; + // Without a previewId there is nothing to present again; without an + // approve-request there is nothing for the holder to approve. Either way this + // is an error like any other and is better surfaced as one than half-handled. if (!previewId || !req || typeof req !== "object") return null; return { @@ -163,6 +181,47 @@ export function disclosureStepUpRequiredFrom(e: unknown): DisclosureStepUpRequir }; } +/** Payload of the `approve-response/0.3` that answers a disclosure step-up. */ +export interface DisclosureApprovalPayload { + subject: string; + sessionId: string; + challenge: string; + decision: "approved" | "denied"; + grantedAcr: string; +} + +/** + * The approve-response payload for a verified disclosure step-up. + * + * Deliberately **not** a signed document. `buildStepUpApproval` exists for the + * did-hosting RP, which is answered outside the channels and so must carry its + * own proof. A disclosure step-up is answered by dispatching an ordinary Trust + * Task to the agent, and the channel signs every outbound document as the + * holder with `proofPurpose: "assertionMethod"` — which is exactly the gate the + * approve-response requires. Building a second proof here would duplicate or + * overwrite that one, which is the reason `provision/integration` is called out + * in this repo's guide as the case that must bypass a channel. + * + * Every echoed field comes from the **verified** request. + */ +export function disclosureApprovalPayload( + request: StepUpApproveRequest, + approved: boolean, +): DisclosureApprovalPayload { + return { + subject: request.subject, + sessionId: request.sessionId, + challenge: request.challenge, + decision: approved ? "approved" : "denied", + grantedAcr: "aal2", + }; +} + +/** `auth/step-up/approve-response/0.3` — the version that can be answered + * `recorded`, which is what a bound disclosure approval must be. */ +export const DISCLOSURE_APPROVE_RESPONSE_TYPE = + "https://trusttasks.org/spec/auth/step-up/approve-response/0.3"; + export interface VerifyDisclosureStepUpOptions { /** The executors this wallet is enrolled with — its agent's DID. The * approve-request's proven signer must be one of them. */ diff --git a/packages/core/tests/persona.step-up.mjs b/packages/core/tests/persona.step-up.mjs index 1b76847..16cbf3b 100644 --- a/packages/core/tests/persona.step-up.mjs +++ b/packages/core/tests/persona.step-up.mjs @@ -10,6 +10,8 @@ import assert from "node:assert/strict"; import { disclosureStepUpRequiredFrom, + disclosureStepUpFrom, + disclosureApprovalPayload, verifyDisclosureStepUp, approveDisclosureStepUp, buildStepUpApproval, @@ -315,3 +317,52 @@ test("the disclosure approval is minted as 0.3, and rp-login's is not", async () assert.equal(proof.verified, true, proof.reason ?? ""); } }); + +test("a refusal that was not thrown is recognised the same way", () => { + // The wallet dispatches this task itself, so the agent's refusal arrives as + // `{code, details}` fields rather than inside an exception. One rule, two + // entry points — a second implementation would be the same three checks + // written twice and would drift on the third change, not the first. + const seen = disclosureStepUpFrom(DISCLOSURE_STEP_UP_REQUIRED_CODE, { + previewId: PREVIEW, + previewRetained: true, + approveRequest: { type: "x", payload: {} }, + }); + assert.ok(seen); + assert.equal(seen.previewId, PREVIEW); + assert.equal(seen.previewRetained, true); + + // And the same strictness: without a previewId there is nothing to present + // again, without an approve-request nothing to approve. + assert.equal(disclosureStepUpFrom(DISCLOSURE_STEP_UP_REQUIRED_CODE, { previewId: PREVIEW }), null); + assert.equal(disclosureStepUpFrom("taskFailed", { previewId: PREVIEW, approveRequest: {} }), null); + assert.equal(disclosureStepUpFrom(undefined, undefined), null, "a success is not a refusal"); +}); + +test("the approval echoes only what the verified request said", async () => { + const seen = disclosureStepUpRequiredFrom( + refusal({ previewId: PREVIEW, previewRetained: true, approveRequest: await approveRequest() }), + ); + const verified = await verifyDisclosureStepUp(seen, enrolled); + assert.ok(verified.ok); + + const payload = disclosureApprovalPayload(verified.request, true); + assert.equal(payload.subject, "did:key:zHolder"); + assert.equal(payload.sessionId, "sess-42"); + assert.equal(payload.challenge, "a".repeat(32)); + assert.equal(payload.decision, "approved"); + + // No proof of its own, deliberately: this goes as an ordinary Trust Task and + // the channel signs it as the holder with `assertionMethod`, which IS the + // gate. A second proof here would duplicate or overwrite that one. + assert.equal("proof" in payload, false); + assert.equal("type" in payload, false, "a payload, not a document"); +}); + +test("a denial is expressible, and says so rather than staying silent", () => { + const payload = disclosureApprovalPayload( + { subject: "did:key:zHolder", sessionId: "s", challenge: "c".repeat(32) }, + false, + ); + assert.equal(payload.decision, "denied"); +}); diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index a22b840..9fb831a 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -9,6 +9,13 @@ // DIDComm flow: content → RUNTIME_LOGIN_DIDCOMM → consent → offscreen doc. import { pageTaskRefusal } from "./page-task-policy.js"; +import { + disclosureStepUpFrom, + verifyDisclosureStepUp, + disclosureApprovalPayload, + DISCLOSURE_APPROVE_RESPONSE_TYPE, + type DisclosureStepUpRequired, +} from "@openvtc/pnm-core/persona"; import { IndexedDBKVStore, listPendingInbound } from "@openvtc/pnm-core"; import { parseActiveVtaDid, @@ -117,6 +124,8 @@ import { RUNTIME_TASK_CONSENT, CONSENT_KEEPALIVE_PORT, RUNTIME_STEP_UP_CONSENT, + type RelayTaskFailure, + type DiscloseResult, RUNTIME_STEP_UP_VTA, RUNTIME_APPROVER_STATE, RUNTIME_RESOLVE_AGENT_NAME, @@ -863,13 +872,21 @@ const DISCLOSURE_CONSENT_PREFIX = "disclosure-consent:"; const PERSONA_PREVIEW = "https://trusttasks.org/spec/persona/disclosure/preview/1.0"; const PERSONA_PRESENT = "https://trusttasks.org/spec/persona/disclosure/present/1.0"; -/** Run one persona task as the wallet, not as the page. */ +/** + * Run one persona task as the wallet, not as the page. + * + * Returns the **relay** shape, not the page-facing one: this caller is the + * wallet, and the wallet has to tell one refusal from another to drive the + * step-up ceremony below (R3.7). Nothing here reaches the page — `handleDisclose` + * returns prose or the presentation, and the `code`/`details` the agent sent + * stop at this file, exactly as `handleRequestTask` narrows them off for a site. + */ async function runPersonaTask( active: { vtaDid: string; restBaseUrl?: string }, origin: string, type: string, payload: Record, -): Promise { +): Promise<{ ok: true; result: unknown } | RelayTaskFailure> { await ensureOffscreenDocument(); return (await chrome.runtime.sendMessage({ target: OFFSCREEN_TARGET, @@ -878,7 +895,7 @@ async function runPersonaTask( restBaseUrl: active.restBaseUrl, origin, params: { type, payload }, - })) as RuntimeRequestTaskResponse; + })) as { ok: true; result: unknown } | RelayTaskFailure; } /** @@ -957,12 +974,121 @@ async function handleDisclose(req: RuntimeDiscloseRequest): Promise + runPersonaTask(active.conn, req.origin, PERSONA_PRESENT, { + contextId: binding.contextId, + previewId: preview.previewId, + }); + + let presented = await present(); + + // `release: stepUp` — the agent wants a fresh authentication bound to THIS + // preview before it will release it (`CLAIM-TYPES.md` §3.2). + // + // Returned as a refusal rather than thrown, and answered here rather than + // handed on. A page that received "Error: stepUpRequired" would be stranded + // at the moment the holder was supposed to act, and the retry the agent + // explicitly offered — `previewRetained: true`, the same preview, once + // approved — would be discarded at the last hop. + const stepUp = disclosureStepUpFrom( + presented.ok ? undefined : presented.code, + presented.ok ? undefined : presented.details, + ); + if (stepUp) { + const done = await runDisclosureStepUp(active.conn, req.origin, stepUp); + if (!done.ok) return { ok: false, error: done.error }; + // The SAME preview. The refusal did not consume it, which is the whole + // reason this is retryable rather than a restart. + presented = await present(); + } + if (!presented.ok) return { ok: false, error: presented.error }; - return { ok: true, result: presented.result ?? {} }; + return { ok: true, result: (presented.result ?? {}) as DiscloseResult }; +} + +/** + * Obtain the fresh approval a `release: stepUp` disclosure needs. + * + * Order is the security property, and it is the same order the RP step-up + * enforces: **verify, then show, then sign.** The approve-request arrives from + * the agent inside the refusal, and everything the holder reads — the verifier, + * the claim types, the purpose — is taken from *inside* its signature. + * `verifyDisclosureStepUp` also refuses a request whose signed `previewId` is + * not the one the refusal named, which is the case where the unsigned half and + * the signed half disagree about which disclosure is being approved. + * + * A refused request returns before any prompt is raised, so the holder is never + * shown a claim list this wallet could not verify. A declined prompt sends + * nothing and the agent's challenge lapses on its TTL. + */ +async function runDisclosureStepUp( + active: { vtaDid: string; restBaseUrl?: string }, + origin: string, + refusal: DisclosureStepUpRequired, +): Promise<{ ok: true } | { ok: false; error: string }> { + const verified = await verifyDisclosureStepUp(refusal, { + // The agent that refused is the only party whose approve-request this + // wallet will act on. + enrolledExecutorDids: [active.vtaDid], + }); + if (!verified.ok) { + return { ok: false, error: `step-up request refused: ${verified.reason}` }; + } + + const approved = await raiseDisclosureStepUpConsent(origin, active.vtaDid, verified.context); + if (!approved) return { ok: false, error: "user declined the step-up approval" }; + + // An ordinary Trust Task. The channel signs it as the holder with + // `assertionMethod`, which IS the gate the approve-response requires — see + // `disclosureApprovalPayload` for why this does not carry a proof of its own. + const answered = await runPersonaTask( + active, + origin, + DISCLOSURE_APPROVE_RESPONSE_TYPE, + disclosureApprovalPayload(verified.request, true) as unknown as Record, + ); + if (!answered.ok) return { ok: false, error: answered.error }; + return { ok: true }; +} + +/** + * Ask the holder to approve **one** disclosure, freshly. + * + * Two departures from `gatedConsent`, and both are the requirement rather than + * caution: + * + * - **`requestConsent` directly, so a trusted origin does not skip it.** + * `gatedConsent` returns `true` outright for an origin the holder ticked + * "remember this site" for. That is right for a login step-up and wrong + * here: `release: stepUp` exists to make the holder decide *each time*, and + * an origin-level grant that answered for them turns "each time" into + * "once per site" — the same failure as binding to the session, reached + * from a different direction. + * - **`noRemember`, so it cannot become one.** There is nothing to remember: + * the approval is bound to a single `previewId` and dies with it. + * + * The text comes from the **verified** context, never the unsigned refusal. + */ +async function raiseDisclosureStepUpConsent( + origin: string, + agentDid: string, + context: { verifierDid?: string; claimTypes: readonly string[]; purpose?: string }, +): Promise { + const what = + context.claimTypes.length === 1 + ? context.claimTypes[0] + : `${context.claimTypes.length} facts (${context.claimTypes.join(", ")})`; + const to = context.verifierDid ? ` to ${context.verifierDid}` : ""; + const why = context.purpose ? ` — they say it is for: ${context.purpose}` : ""; + const { approved } = await requestConsent({ + origin, + rpDid: agentDid, + noRemember: true, + stepUp: true, + action: "approve this disclosure", + reason: `Release ${what}${to}${why}. This approval covers this one disclosure.`, + }); + return approved; } /** The persona and context this origin's stored profile entry names. */ From e5fcc5a3de437bbbbed202ef8f54423f5481c447 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Tue, 8 Sep 2026 13:30:50 +0200 Subject: [PATCH 2/2] fix(persona): run the disclosure step-up in the offscreen, not the worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught this before it shipped: `background.js` gained a dynamic `import()`, which an MV3 service worker cannot load. The cause is structural, not a stray import — verifying the agent's approve-request resolves a DID, and DID resolution cannot be statically bundled into a worker. That is exactly why `doStepUpVta` verifies in the offscreen document and asks BACK to the background for the prompt. I had mirrored the wrong half. The verify and the signing now live in the offscreen where they can, and the background contributes the only thing it uniquely can: a window for the human. The prompt is its own message rather than a reuse of RUNTIME_STEP_UP_CONSENT, because that one is answered through `gatedConsent`, which returns true outright for a remembered origin — right for a login step-up, wrong where the whole requirement is that the holder decides each time. The guard is documented in this repo's own CLAUDE.md and I did not run it; only `npm test` and a build whose output I grepped for the word "error". Running the job's actual assertions is the check that would have caught it locally. Signed-off-by: Glenn Gore --- packages/extension/src/background.ts | 89 ++++++++++++----------- packages/extension/src/bridge-protocol.ts | 63 ++++++++++++++++ packages/extension/src/offscreen.ts | 83 +++++++++++++++++++++ 3 files changed, 193 insertions(+), 42 deletions(-) diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 9fb831a..6630647 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -11,9 +11,6 @@ import { pageTaskRefusal } from "./page-task-policy.js"; import { disclosureStepUpFrom, - verifyDisclosureStepUp, - disclosureApprovalPayload, - DISCLOSURE_APPROVE_RESPONSE_TYPE, type DisclosureStepUpRequired, } from "@openvtc/pnm-core/persona"; import { IndexedDBKVStore, listPendingInbound } from "@openvtc/pnm-core"; @@ -124,6 +121,12 @@ import { RUNTIME_TASK_CONSENT, CONSENT_KEEPALIVE_PORT, RUNTIME_STEP_UP_CONSENT, + OFFSCREEN_DISCLOSURE_STEP_UP, + RUNTIME_DISCLOSURE_STEP_UP_CONSENT, + type OffscreenDisclosureStepUpRequest, + type OffscreenDisclosureStepUpResponse, + type RuntimeDisclosureStepUpConsentRequest, + type RuntimeDisclosureStepUpConsentResponse, type RelayTaskFailure, type DiscloseResult, RUNTIME_STEP_UP_VTA, @@ -1009,46 +1012,36 @@ async function handleDisclose(req: RuntimeDiscloseRequest): Promise { - const verified = await verifyDisclosureStepUp(refusal, { - // The agent that refused is the only party whose approve-request this - // wallet will act on. - enrolledExecutorDids: [active.vtaDid], - }); - if (!verified.ok) { - return { ok: false, error: `step-up request refused: ${verified.reason}` }; - } - - const approved = await raiseDisclosureStepUpConsent(origin, active.vtaDid, verified.context); - if (!approved) return { ok: false, error: "user declined the step-up approval" }; - - // An ordinary Trust Task. The channel signs it as the holder with - // `assertionMethod`, which IS the gate the approve-response requires — see - // `disclosureApprovalPayload` for why this does not carry a proof of its own. - const answered = await runPersonaTask( - active, + await ensureOffscreenDocument(); + const ask: OffscreenDisclosureStepUpRequest = { + target: OFFSCREEN_TARGET, + type: OFFSCREEN_DISCLOSURE_STEP_UP, + vtaDid: active.vtaDid, + ...(active.restBaseUrl !== undefined ? { restBaseUrl: active.restBaseUrl } : {}), origin, - DISCLOSURE_APPROVE_RESPONSE_TYPE, - disclosureApprovalPayload(verified.request, true) as unknown as Record, - ); - if (!answered.ok) return { ok: false, error: answered.error }; - return { ok: true }; + refusal: { + previewId: refusal.previewId, + previewRetained: refusal.previewRetained, + unverifiedApproveRequest: refusal.unverifiedApproveRequest, + }, + }; + const result = (await chrome.runtime.sendMessage(ask)) as + | OffscreenDisclosureStepUpResponse + | undefined; + return result ?? { ok: false, error: "the step-up ceremony returned nothing" }; } /** @@ -1069,11 +1062,11 @@ async function runDisclosureStepUp( * * The text comes from the **verified** context, never the unsigned refusal. */ -async function raiseDisclosureStepUpConsent( - origin: string, - agentDid: string, - context: { verifierDid?: string; claimTypes: readonly string[]; purpose?: string }, -): Promise { +async function handleDisclosureStepUpConsent( + req: RuntimeDisclosureStepUpConsentRequest, +): Promise { + const { origin, agentDid } = req; + const context = { verifierDid: req.verifierDid, claimTypes: req.claimTypes, purpose: req.purpose }; const what = context.claimTypes.length === 1 ? context.claimTypes[0] @@ -1088,7 +1081,7 @@ async function raiseDisclosureStepUpConsent( action: "approve this disclosure", reason: `Release ${what}${to}${why}. This approval covers this one disclosure.`, }); - return approved; + return { approved }; } /** The persona and context this origin's stored profile entry names. */ @@ -3077,6 +3070,18 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; // async sendResponse } + if ((message as { type?: string })?.type === RUNTIME_DISCLOSURE_STEP_UP_CONSENT) { + handleDisclosureStepUpConsent(message as RuntimeDisclosureStepUpConsentRequest) + .then(sendResponse) + // A denial, for the same reason as every other prompt here: silence is + // not agreement — least of all on the surface deciding whether a card + // number leaves. + .catch(() => + sendResponse({ approved: false } satisfies RuntimeDisclosureStepUpConsentResponse), + ); + return true; // async sendResponse + } + if ((message as { type?: string })?.type === RUNTIME_MANAGER_TASK) { // Extension pages only. A content script carries our extension id but not // our URL, and this relay does not stop to ask a human — so the gate is the diff --git a/packages/extension/src/bridge-protocol.ts b/packages/extension/src/bridge-protocol.ts index ede78ba..c273b40 100644 --- a/packages/extension/src/bridge-protocol.ts +++ b/packages/extension/src/bridge-protocol.ts @@ -1803,6 +1803,69 @@ export interface OffscreenRestLoginRequest { * [`RuntimeLoginResponse`] via `sendResponse`. Mid-flow the offscreen calls * back with a [`RuntimeStepUpConsentRequest`] once the approve-request has * verified — the background raises the consent prompt then, not before. */ +/** background → offscreen: obtain the fresh approval a `release: stepUp` + * disclosure needs. + * + * **In the offscreen, not the background, and the reason is structural.** + * Verifying the agent's approve-request resolves a DID, and DID resolution + * cannot be statically bundled into an MV3 service worker — a dynamic + * `import()` in `background.js` is the one thing CI asserts is absent, because + * a service worker cannot load one. So the verify and the signing both happen + * here and the background contributes the only thing it uniquely can: a + * window for the human. Exactly the shape `OFFSCREEN_STEP_UP_VTA` already has. + * + * Reply is an [`OffscreenDisclosureStepUpResponse`]. */ +export const OFFSCREEN_DISCLOSURE_STEP_UP = "pnm/offscreen-disclosure-step-up" as const; + +export interface OffscreenDisclosureStepUpRequest { + target: typeof OFFSCREEN_TARGET; + type: typeof OFFSCREEN_DISCLOSURE_STEP_UP; + /** The agent that refused, and the transport to answer it on. */ + vtaDid: string; + restBaseUrl?: string; + /** The requesting page's origin — display only, for the prompt. */ + origin: string; + /** The refusal, verbatim. Its `approveRequest` is UNVERIFIED here; nothing + * in it may be shown until `verifyDisclosureStepUp` has passed. */ + refusal: { + previewId: string; + previewRetained: boolean; + unverifiedApproveRequest: Record; + }; +} + +export type OffscreenDisclosureStepUpResponse = + | { ok: true } + | { ok: false; error: string }; + +/** offscreen → background: raise the DISCLOSURE step-up prompt for a VERIFIED + * approve-request. Everything here came out of the signature. + * + * Deliberately its own message rather than reusing [`RUNTIME_STEP_UP_CONSENT`]: + * that one is answered through `gatedConsent`, which returns true outright for + * an origin the holder ticked "remember this site" for. Right for a login + * step-up; wrong for this one, where the whole requirement is that the holder + * decides *each time*. An origin-level grant answering for them would turn + * "each time" into "once per site". */ +export const RUNTIME_DISCLOSURE_STEP_UP_CONSENT = "vta-wallet/disclosure-step-up-consent" as const; + +export interface RuntimeDisclosureStepUpConsentRequest { + type: typeof RUNTIME_DISCLOSURE_STEP_UP_CONSENT; + origin: string; + /** The agent that asked — the proven signer of the approve-request. */ + agentDid: string; + /** From the verified context. Who would receive the claims. */ + verifierDid?: string; + /** From the verified context. What would leave. */ + claimTypes: string[]; + /** From the verified context. The verifier's stated reason, if any. */ + purpose?: string; +} + +export interface RuntimeDisclosureStepUpConsentResponse { + approved: boolean; +} + export interface OffscreenStepUpVtaRequest { target: typeof OFFSCREEN_TARGET; type: typeof OFFSCREEN_STEP_UP_VTA; diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 67bdaa9..44f7730 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -77,6 +77,11 @@ import { VtaSession, verifyDid, } from "@openvtc/pnm-core"; +import { + verifyDisclosureStepUp, + disclosureApprovalPayload, + DISCLOSURE_APPROVE_RESPONSE_TYPE, +} from "@openvtc/pnm-core/persona"; import { base64url } from "@openvtc/vti-didcomm-js"; import { grantCommand } from "./grant-command.js"; import { forgetInbox, getSettings, inboxFor, inboxToAdopt, setInbox } from "./config.js"; @@ -123,6 +128,11 @@ import { OFFSCREEN_SIGN_TRUST_TASK, OFFSCREEN_START_INBOUND, OFFSCREEN_STEP_UP_VTA, + OFFSCREEN_DISCLOSURE_STEP_UP, + RUNTIME_DISCLOSURE_STEP_UP_CONSENT, + type OffscreenDisclosureStepUpRequest, + type RuntimeDisclosureStepUpConsentRequest, + type RuntimeDisclosureStepUpConsentResponse, OFFSCREEN_TARGET, OFFSCREEN_VAULT_DELETE, OFFSCREEN_REQUEST_TASK, @@ -231,6 +241,14 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { ); return true; // async sendResponse } + if (msg.type === OFFSCREEN_DISCLOSURE_STEP_UP) { + doDisclosureStepUp(message as OffscreenDisclosureStepUpRequest) + .then(sendResponse) + .catch((e: unknown) => + sendResponse({ ok: false, error: e instanceof Error ? e.message : String(e) }), + ); + return true; // async sendResponse + } if (msg.type === OFFSCREEN_STEP_UP_VTA) { doStepUpVta(message as OffscreenStepUpVtaRequest) .then(sendResponse) @@ -3226,6 +3244,71 @@ async function doDidcommLogin( }; } +/** + * Obtain the fresh approval a `release: stepUp` disclosure needs. + * + * The same enforced order as `doStepUpVta`: **verify, then show, then sign.** + * Everything the human reads is taken from *inside* the agent's signature, per + * the spec's "consumers MUST verify the proof BEFORE surfacing the reason" — + * and here the reason is the list of facts about to leave, so the rule matters + * more rather than less. + * + * `verifyDisclosureStepUp` adds the check the generic verifier cannot make: the + * `previewId` inside the signature must equal the one the refusal named. The + * refusal's copy is unsigned, so approving against it would mean the holder + * read a prompt describing one disclosure and authorised whichever the + * signature meant. + * + * A refused request returns before the prompt, so the holder is never shown a + * claim list this wallet could not verify. A declined prompt sends nothing and + * the agent's challenge lapses on its TTL. + */ +async function doDisclosureStepUp( + req: OffscreenDisclosureStepUpRequest, +): Promise<{ ok: true } | { ok: false; error: string }> { + const verified = await verifyDisclosureStepUp( + { kind: "stepUpRequired", ...req.refusal }, + { enrolledExecutorDids: await enrolledExecutorDids(req.vtaDid) }, + ); + if (!verified.ok) return { ok: false, error: `step-up request refused: ${verified.reason}` }; + + const ask: RuntimeDisclosureStepUpConsentRequest = { + type: RUNTIME_DISCLOSURE_STEP_UP_CONSENT, + origin: req.origin, + agentDid: verified.issuer, + claimTypes: [...verified.context.claimTypes], + ...(verified.context.verifierDid !== undefined + ? { verifierDid: verified.context.verifierDid } + : {}), + ...(verified.context.purpose !== undefined ? { purpose: verified.context.purpose } : {}), + }; + const decision = (await chrome.runtime.sendMessage(ask)) as + | RuntimeDisclosureStepUpConsentResponse + | undefined; + // Anything but an explicit true — a vanished background, a malformed reply — + // is a denial. A prompt the holder never saw must not become an approval. + if (decision?.approved !== true) return { ok: false, error: "user declined the step-up approval" }; + + // An ordinary Trust Task: the channel signs it as the holder with + // `assertionMethod`, which IS the gate the approve-response requires, so the + // payload carries no proof of its own. + await doRequestTask({ + target: OFFSCREEN_TARGET, + type: OFFSCREEN_REQUEST_TASK, + vtaDid: req.vtaDid, + ...(req.restBaseUrl !== undefined ? { restBaseUrl: req.restBaseUrl } : {}), + origin: req.origin, + params: { + type: DISCLOSURE_APPROVE_RESPONSE_TYPE, + payload: disclosureApprovalPayload(verified.request, true) as unknown as Record< + string, + unknown + >, + }, + } as OffscreenRequestTaskRequest); + return { ok: true }; +} + async function doStepUpVta( req: OffscreenStepUpVtaRequest, ): Promise {