From 6edd1e0b7ea703d4bad0c55188a8cb68b33b1d8a Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Wed, 29 Jul 2026 16:30:59 +0800 Subject: [PATCH] feat: prompt step-up consent on the VERIFIED reason, mid-flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flagged follow-up from #103: the step-up consent prompt fired before the RP start fetch, so the human decided on origin/rpDid alone and the signed reason — the thing #103 made verifiable — was never shown. The spec's rule is 'consumers MUST verify the proof BEFORE surfacing the reason'; we verified but never surfaced. Reorder the flow so consent sits between verification and signing: - core: new performStepUpVta owns the enforced order — start -> verify (signed approve-request; proof + enrolled-executor signer + issuer == page rpDid) -> consent callback -> sign approve-response -> finish. A refused approve-request returns before the callback, so no prompt is ever raised for unverifiable content; a decline sends nothing (the RP's challenge lapses on its TTL). Unit-tested against a mock RP: prompt content comes from inside the signature (a tampered unsigned copy is never shown), declined sends nothing, missing document and issuer/rpDid mismatch both refuse without prompting. - extension background: handleStepUpVta no longer pre-prompts; it forwards to the offscreen, threading the browser-attested origin. The new mid-flow RUNTIME_STEP_UP_CONSENT (offscreen -> background) raises the prompt through the same gatedConsent gate as before — the 'remember this site' origin-trust short-circuit keeps its pre-#103 semantics — with the verified reason length-capped (500 chars) and control/bidi-character-stripped before it reaches the popup. - confirm popup: step-up framing ('Step-up approval request') plus a reason card that renders the RP's verified reason as plain text (React text nodes, no markup), visually attributed to the verified RP DID card with an explicit 'their claim' note. A document with no reason falls back to the previous origin/rpDid-only prompt. Security invariants from #103 unchanged: no prompt on missing/invalid document, issuer/rpDid binding intact, response signing only after explicit user approval. Part of the step-up programme (affinidi/affinidi-webvh-service#147). Signed-off-by: Glenn Gore --- packages/core/src/rp-login/step-up.ts | 142 +++++++++++++++++++ packages/core/tests/rp-login.step-up.mjs | 163 ++++++++++++++++++++++ packages/extension/src/background.ts | 89 ++++++++++-- packages/extension/src/bridge-protocol.ts | 37 ++++- packages/extension/src/confirm.tsx | 79 ++++++++++- packages/extension/src/offscreen.ts | 102 ++++++-------- 6 files changed, 539 insertions(+), 73 deletions(-) diff --git a/packages/core/src/rp-login/step-up.ts b/packages/core/src/rp-login/step-up.ts index 5753e68..72527c4 100644 --- a/packages/core/src/rp-login/step-up.ts +++ b/packages/core/src/rp-login/step-up.ts @@ -287,6 +287,148 @@ export interface StepUpVtaFinishResult { sessionId: string; } +/** What the consent surface may show the human for a step-up. Every member is + * taken from *inside* the verified approve-request document (or is the + * page-supplied `rpDid` after it has been checked equal to the proven + * issuer) — nothing here predates verification. */ +export interface StepUpConsentContext { + /** The proven signer of the approve-request (== the page's `rpDid`). */ + issuer: string; + /** The session subject being elevated, from the verified payload. */ + subject: string; + /** The RP session being elevated, from the verified payload. */ + sessionId: string; + /** The RP's human-readable reason, from the verified payload. Absent when + * the signed document carried none — the prompt then falls back to its + * origin/rpDid-only text. */ + reason?: string; +} + +export interface PerformStepUpVtaArgs { + baseUrl: string; + accessToken: string; + /** The wallet's signing identity — must be the DID the RP session + * authenticated as (it signs the approve-response). */ + signing: SigningIdentity; + /** The RP DID the page claimed. The verified approve-request's issuer must + * equal it, and the approve-response is audience-bound to it. */ + rpDid: string; + /** Executors this wallet is enrolled with; the approve-request's proven + * signer must be in this set. */ + enrolledExecutorDids: readonly string[]; + /** + * Ask the human. Called ONLY after the signed approve-request verified — + * the `reason` it receives comes from inside the signature, which is what + * lets the prompt show it at all (spec: "consumers MUST verify the proof + * BEFORE surfacing the reason"). Return `false` to decline: nothing is + * signed and nothing is sent to the RP — the pending challenge simply + * lapses server-side. + */ + requestConsent: (ctx: StepUpConsentContext) => Promise; + fetchFn?: typeof fetch; + /** Timing hook — called as each flow step completes. */ + onMark?: (label: string) => void; + /** Defaults to now. Injected for tests. */ + now?: Date; +} + +export type PerformStepUpVtaResult = + | { ok: true; tokens: StepUpVtaFinishResult } + | { + ok: false; + error: string; + /** True when the human declined the prompt (as opposed to the + * approve-request being refused before any prompt was shown). */ + declined: boolean; + }; + +/** + * The whole holder-side step-up flow, in its enforced order: + * + * 1. RP `start` (REST) → the signed `approve-request` document + * 2. verify it ({@link verifyStepUpApproveRequest}) + issuer == `rpDid` + * 3. `requestConsent` — the human decides on the VERIFIED reason + * 4. only on approval: sign the `approve-response` and `finish` (REST) + * + * The consent prompt deliberately sits *inside* this function, between + * verification and signing: before it, and the human would be deciding on + * words nobody has authenticated; after it, and the wallet would have signed + * before anyone consented. A decline sends nothing — the RP's challenge + * expires on its own TTL, so the prompt must be answered within the + * challenge's validity window. + */ +export async function performStepUpVta( + args: PerformStepUpVtaArgs, +): Promise { + const mark = args.onMark ?? (() => {}); + const refuse = (error: string): PerformStepUpVtaResult => ({ + ok: false, + error, + declined: false, + }); + + // 1. RP start (REST) → the signed `auth/step-up/approve-request/0.2` + // Trust-Task document (plus legacy top-level fields for cross-checking). + const start = await stepUpVtaStart(args.baseUrl, args.accessToken, args.fetchFn); + mark("rp start (challenge)"); + + // 2. Verify BEFORE acting on anything in it — a start response with no + // `document`, a bad proof, or a signer outside the enrolled-executor set + // is refused here, and the human never sees a prompt. + const verified = await verifyStepUpApproveRequest(start, { + enrolledExecutorDids: args.enrolledExecutorDids, + ...(args.now ? { now: args.now } : {}), + }); + if (!verified.ok) { + return refuse(`step-up approve-request refused: ${verified.reason}`); + } + // The RP the page named is the audience the approve-response will be bound + // to (`recipient: rpDid`); the approve-request's proven issuer must be that + // same party, or the wallet would be answering a question nobody it trusts + // asked. + if (verified.issuer !== args.rpDid) { + return refuse( + `step-up approve-request refused: issuer ${verified.issuer} does not match the page-supplied rpDid`, + ); + } + mark("verify approve-request"); + + // 3. The human decides, on fields that came from inside the signature. + const consented = await args.requestConsent({ + issuer: verified.issuer, + subject: verified.request.subject, + sessionId: verified.request.sessionId, + ...(typeof verified.request.reason === "string" + ? { reason: verified.request.reason } + : {}), + }); + if (!consented) { + // Declined = nothing leaves the wallet. No denied approve-response is + // sent; the RP's pending challenge lapses on its TTL. + return { ok: false, error: "step-up denied by user", declined: true }; + } + mark("user consent"); + + // 4. Sign the approve-response/0.2 locally (holder-self-signs — no VTA + // round-trip). Every echoed field comes from the *verified* payload. + const approval = await buildStepUpApproval({ + signing: args.signing, + rpDid: args.rpDid, + request: verified.request, + approved: true, + }); + mark("sign approval"); + + const tokens = await stepUpVtaFinish( + args.baseUrl, + args.accessToken, + approval, + args.fetchFn, + ); + mark("rp finish (elevate)"); + return { ok: true, tokens }; +} + /** * Step 3 — RP finish. Submits the signed `approve-response/0.2` document and * returns the elevated session tokens. Response body is **snake_case**. diff --git a/packages/core/tests/rp-login.step-up.mjs b/packages/core/tests/rp-login.step-up.mjs index 9fec3ff..019add7 100644 --- a/packages/core/tests/rp-login.step-up.mjs +++ b/packages/core/tests/rp-login.step-up.mjs @@ -10,6 +10,7 @@ import assert from "node:assert/strict"; import { buildStepUpApproval, + performStepUpVta, verifyStepUpApproveRequest, verifyTrustTaskProof, generateSigningIdentity, @@ -194,3 +195,165 @@ test("verifyStepUpApproveRequest: a lapsed request is refused", async () => { assert.equal(res.ok, false); assert.match(res.reason, /lapsed/); }); + +// ── performStepUpVta: the whole flow, in its enforced order ────────────────── +// +// start → verify → CONSENT → sign → finish. The consent callback stands in +// for the human: it must be shown only post-verification content (the reason +// from inside the signature), a decline must send nothing to the RP, and a +// refused approve-request must never reach it at all. + +/** Mock the RP's two REST endpoints; records every request it serves. */ +function mockRp(startBody) { + const calls = []; + const fetchFn = async (url, init) => { + const u = String(url); + calls.push({ url: u, body: init?.body ? JSON.parse(init.body) : undefined }); + if (u.endsWith("/auth/step-up/vta/start")) { + return new Response(JSON.stringify(startBody), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (u.endsWith("/auth/step-up/vta/finish")) { + return new Response( + JSON.stringify({ + session_id: "sess-42", + access_token: "elevated-access", + refresh_token: "elevated-refresh", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + return new Response("not found", { status: 404 }); + }; + return { fetchFn, calls }; +} + +function flowArgs(holder, fetchFn, requestConsent) { + return { + baseUrl: "https://rp.example", + accessToken: "aal1-token", + signing: holder, + rpDid: RP.did, + enrolledExecutorDids: [RP.did], + fetchFn, + requestConsent, + }; +} + +test("performStepUpVta: consent sees the reason from INSIDE the signed document, then sign+finish", async () => { + const holder = generateSigningIdentity(); + const start = await startResponse(); + // Tamper the unsigned top-level reason — the human must never see this copy. + start.reason = "Totally harmless, click approve."; + const { fetchFn, calls } = mockRp(start); + + const seen = []; + const res = await performStepUpVta( + flowArgs(holder, fetchFn, async (ctx) => { + seen.push(ctx); + return true; + }), + ); + + assert.equal(res.ok, true, res.ok ? undefined : res.error); + assert.equal(res.tokens.accessToken, "elevated-access"); + assert.equal(res.tokens.refreshToken, "elevated-refresh"); + assert.equal(res.tokens.sessionId, "sess-42"); + + // The prompt content is the verified payload, not the unsigned echo. + assert.equal(seen.length, 1); + assert.equal(seen[0].reason, "Confirm the transfer of $1,000 to ACME Corp."); + assert.equal(seen[0].issuer, RP.did); + assert.equal(seen[0].subject, "did:key:zSubject"); + assert.equal(seen[0].sessionId, "sess-42"); + + // finish carried a signed approve-response echoing only verified fields. + const finish = calls.find((c) => c.url.endsWith("/finish")); + assert.ok(finish, "finish was called"); + assert.equal(finish.body.type, APPROVE_RESPONSE_TYPE); + assert.equal(finish.body.payload.decision, "approved"); + assert.equal(finish.body.payload.challenge, "a".repeat(32)); + const proofCheck = await verifyTrustTaskProof(finish.body, { + expectedProofPurpose: "assertionMethod", + }); + assert.equal(proofCheck.verified, true, proofCheck.reason); + assert.equal(proofCheck.signer, holder.did); +}); + +test("performStepUpVta: a document with no reason still prompts — with no reason member", async () => { + const holder = generateSigningIdentity(); + const start = await startResponse({ unsigned: true, legacy: false }); + delete start.document.payload.reason; // the RP signed a payload with no reason + await signTrustTask({ envelope: start.document, signing: RP }); + const { fetchFn } = mockRp(start); + + const seen = []; + const res = await performStepUpVta( + flowArgs(holder, fetchFn, async (ctx) => { + seen.push(ctx); + return true; + }), + ); + assert.equal(res.ok, true, res.ok ? undefined : res.error); + assert.equal(seen.length, 1); + assert.equal("reason" in seen[0], false); +}); + +test("performStepUpVta: declined prompt sends NOTHING to the RP", async () => { + const holder = generateSigningIdentity(); + const { fetchFn, calls } = mockRp(await startResponse()); + + const res = await performStepUpVta(flowArgs(holder, fetchFn, async () => false)); + + assert.equal(res.ok, false); + assert.equal(res.declined, true); + assert.match(res.error, /denied by user/); + // Only the start fetch happened — no finish, no denied approve-response. + assert.deepEqual( + calls.map((c) => c.url), + ["https://rp.example/auth/step-up/vta/start"], + ); +}); + +test("performStepUpVta: missing document refuses WITHOUT prompting", async () => { + const holder = generateSigningIdentity(); + const { fetchFn, calls } = mockRp(await startResponse({ withDocument: false })); + + let prompted = false; + const res = await performStepUpVta( + flowArgs(holder, fetchFn, async () => { + prompted = true; + return true; + }), + ); + assert.equal(res.ok, false); + assert.equal(res.declined, false); + assert.match(res.error, /no signed approve-request document/); + assert.equal(prompted, false, "no prompt for an unverifiable request"); + assert.equal(calls.filter((c) => c.url.endsWith("/finish")).length, 0); +}); + +test("performStepUpVta: issuer ≠ page rpDid refuses WITHOUT prompting", async () => { + const holder = generateSigningIdentity(); + // Signed by an executor the wallet IS enrolled with — but not the RP the + // page named. Verification alone passes; the binding check must still stop + // the flow before any human is asked. + const other = generateSigningIdentity(); + const { fetchFn, calls } = mockRp(await startResponse({ as: other, legacy: false })); + + let prompted = false; + const res = await performStepUpVta({ + ...flowArgs(holder, fetchFn, async () => { + prompted = true; + return true; + }), + enrolledExecutorDids: [RP.did, other.did], + }); + assert.equal(res.ok, false); + assert.equal(res.declined, false); + assert.match(res.error, /does not match the page-supplied rpDid/); + assert.equal(prompted, false); + assert.equal(calls.filter((c) => c.url.endsWith("/finish")).length, 0); +}); diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index de255ff..99af0e5 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -88,6 +88,7 @@ import { RUNTIME_REQUEST_TASK, RUNTIME_SIGN_TRUST_TASK, RUNTIME_TASK_CONSENT, + RUNTIME_STEP_UP_CONSENT, RUNTIME_STEP_UP_VTA, RUNTIME_VERIFY_RP_DID, RUNTIME_WALLET_DEFAULTS, @@ -128,6 +129,8 @@ import { type RuntimeRequestTaskResponse, type RuntimeSignTrustTaskRequest, type RuntimeSignTrustTaskResponse, + type RuntimeStepUpConsentRequest, + type RuntimeStepUpConsentResponse, type RuntimeStepUpVtaRequest, type RuntimeVaultDeleteRequest, type RuntimeVaultDeleteResponse, @@ -527,6 +530,13 @@ async function requestConsent(args: { * operator has to explicitly approve the swap. */ changedFromRpDid?: string; + /** Frames the prompt as a session step-up rather than a sign-in. */ + stepUp?: boolean; + /** VERIFIED RP-authored reason to render (plain text, already length-capped + * and control-stripped by the caller). Only ever set from the step-up path, + * where it comes from inside the signed approve-request — never pass a + * page-supplied string here. */ + reason?: string; }): Promise<{ approved: boolean; remember: boolean }> { const consentId = crypto.randomUUID(); const url = @@ -537,11 +547,14 @@ async function requestConsent(args: { (args.holderDid ? `&holder=${encodeURIComponent(args.holderDid)}` : "") + (args.action ? `&action=${encodeURIComponent(args.action)}` : "") + (args.noRemember ? `&noRemember=1` : "") + + (args.stepUp ? `&stepUp=1` : "") + + (args.reason ? `&reason=${encodeURIComponent(args.reason)}` : "") + (args.changedFromRpDid ? `&changedFrom=${encodeURIComponent(args.changedFromRpDid)}` : ""); - const bounds = await consentWindowBounds(560); + // The reason card needs room, or the decision buttons slide off-screen. + const bounds = await consentWindowBounds(args.reason ? 660 : 560); return new Promise<{ approved: boolean; remember: boolean }>((resolve) => { let settled = false; @@ -645,6 +658,8 @@ async function gatedConsent(args: { holderDid?: string; action?: string; changedFromRpDid?: string; + stepUp?: boolean; + reason?: string; }): Promise { // A pinned-RP *change* must always re-prompt, even for a trusted site — // it's exactly the redirect-to-attacker-RP case the louder warning exists @@ -752,27 +767,72 @@ async function handleLoginDidcomm( async function handleStepUpVta( req: RuntimeStepUpVtaRequest, ): Promise { - // Display-only DID lookup — see handleLoginDidcomm for the - // background-vs-offscreen scope rationale. + // Fast-fail without spinning up the offscreen document. Display-only DID + // lookup — see handleLoginDidcomm for the background-vs-offscreen rationale. const holderDid = await readActiveHolderDid(); if (!holderDid) return { ok: false, error: "no active VTA connection — connect first" }; - const approved = await gatedConsent({ - origin: req.origin, - rpDid: req.params.rpDid, - holderDid, - }); - if (!approved) return { ok: false, error: "step-up denied by user" }; - + // NO consent prompt here. The step-up prompt fires mid-flow instead: the + // offscreen fetches the RP `start` response, verifies the signed + // approve-request (proof + enrolled-executor signer + issuer == rpDid), and + // only then asks back via RUNTIME_STEP_UP_CONSENT — so the prompt can show + // the human the VERIFIED `reason` from inside the signature. Prompting + // before the fetch (the old shape) showed origin/rpDid only and left the + // signed reason unread, which defeated the point of signing it (the spec's + // rule is verify-BEFORE-surfacing, not verify-instead-of-surfacing). + // Nothing is signed or sent unless that prompt approves. await ensureOffscreenDocument(); const offscreenRequest: OffscreenStepUpVtaRequest = { target: OFFSCREEN_TARGET, type: OFFSCREEN_STEP_UP_VTA, params: req.params, + origin: req.origin, }; return (await chrome.runtime.sendMessage(offscreenRequest)) as RuntimeLoginResponse; } +/** Longest RP-authored reason the consent prompt will carry. Anything past + * this is truncated with an ellipsis — the prompt is a decision surface, not + * a document viewer, and an unbounded string in a query param is asking for + * trouble. */ +const MAX_STEP_UP_REASON_CHARS = 500; + +/** + * The offscreen's mid-flow step-up consent ask (RUNTIME_STEP_UP_CONSENT). + * Reached only after the approve-request verified, so the `reason` shown here + * is attributable to the proven issuer. Still routed through `gatedConsent`: + * an origin the user ticked "remember this site" for keeps skipping the + * prompt, exactly as the pre-#103 step-up prompt did — the reorder changes + * *when* the prompt fires and *what it shows*, not who sees one. + */ +async function handleStepUpConsent( + req: RuntimeStepUpConsentRequest, +): Promise { + // Untrusted-but-attributed prose: cap the length and strip control + // characters (bidi overrides, escapes) that could visually reorder or hide + // parts of what the human reads. React already renders it as plain text — + // this guards the *legibility* of the string, not just its inertness. + const cleaned = req.reason + ?.replace( + // eslint-disable-next-line no-control-regex -- stripping controls is the point + /[\u0000-\u0008\u000B-\u001F\u007F\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, + "", + ) + .trim(); + const reason = + cleaned && cleaned.length > MAX_STEP_UP_REASON_CHARS + ? `${cleaned.slice(0, MAX_STEP_UP_REASON_CHARS)}…` + : cleaned; + const approved = await gatedConsent({ + origin: req.origin, + rpDid: req.rpDid, + holderDid: req.holderDid, + stepUp: true, + ...(reason ? { reason } : {}), + }); + return { approved }; +} + // Page-facing authenticated fetches must never hang the requesting page against // a blackholed / wedged VTA: bound every one with an abort timeout (R1.2). A // stalled VTA then surfaces as a clean `{ ok: false, error }` (via the message @@ -1715,6 +1775,15 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; // async sendResponse } + if ((message as { type?: string })?.type === RUNTIME_STEP_UP_CONSENT) { + handleStepUpConsent(message as RuntimeStepUpConsentRequest) + .then(sendResponse) + // Any failure to raise or resolve the prompt is a denial — silence is + // not agreement, here as everywhere else in this file. + .catch(() => sendResponse({ approved: false } satisfies RuntimeStepUpConsentResponse)); + return true; // async sendResponse + } + if ((message as { type?: string })?.type === RUNTIME_REQUEST_TASK) { handleRequestTask(message as RuntimeRequestTaskRequest) .then(sendResponse) diff --git a/packages/extension/src/bridge-protocol.ts b/packages/extension/src/bridge-protocol.ts index a1e3316..5a78a38 100644 --- a/packages/extension/src/bridge-protocol.ts +++ b/packages/extension/src/bridge-protocol.ts @@ -267,6 +267,14 @@ export const RUNTIME_CONSENT_RESULT = "vta-wallet/consent-result" as const; * enrolled executor is asking, not a site), and its approval is single-use so * there is nothing to remember. */ export const RUNTIME_TASK_CONSENT = "vta-wallet/task-consent" as const; +/** offscreen → background: a step-up approve-request has VERIFIED and the + * human must now decide. Fired mid-flow — after the offscreen fetched the RP + * `start` response and `verifyStepUpApproveRequest` passed, before anything + * is signed — so the prompt can render the `reason` from *inside* the signed + * document (spec: "consumers MUST verify the proof BEFORE surfacing the + * reason"). Reply via sendResponse is a [`RuntimeStepUpConsentResponse`]; + * anything but `approved: true` means nothing is signed or sent. */ +export const RUNTIME_STEP_UP_CONSENT = "vta-wallet/step-up-consent" as const; /** confirm popup → background → offscreen: resolve + verify an RP DID so the * consent prompt can render a verification badge. Reply via sendResponse is a * [`VerifyDidResult`]. */ @@ -1382,11 +1390,38 @@ export interface OffscreenRestLoginRequest { } /** background → offscreen: run a VTA-approval step-up. Reply is a - * [`RuntimeLoginResponse`] via `sendResponse`. */ + * [`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. */ export interface OffscreenStepUpVtaRequest { target: typeof OFFSCREEN_TARGET; type: typeof OFFSCREEN_STEP_UP_VTA; params: StepUpVtaParams; + /** The RP page's origin — threaded through so the mid-flow consent prompt + * can show it (and honour per-origin trust). Display only, never auth. */ + origin: string; +} + +/** offscreen → background: raise the step-up consent prompt for a VERIFIED + * approve-request. Every member below is post-verification: `rpDid` has been + * checked equal to the document's proven issuer, and `reason` comes from + * inside the signature — never from the unsigned start-response copy. */ +export interface RuntimeStepUpConsentRequest { + type: typeof RUNTIME_STEP_UP_CONSENT; + /** The RP page's origin, echoed from the [`OffscreenStepUpVtaRequest`]. */ + origin: string; + /** The RP DID (== the approve-request's proven issuer). */ + rpDid: string; + /** The holder DID that will sign the approve-response. */ + holderDid: string; + /** The RP's reason from inside the verified document. Absent when the + * signed payload carried none — the prompt then shows its plain + * origin/rpDid text. */ + reason?: string; +} + +export interface RuntimeStepUpConsentResponse { + approved: boolean; } diff --git a/packages/extension/src/confirm.tsx b/packages/extension/src/confirm.tsx index 187692f..3b72d81 100644 --- a/packages/extension/src/confirm.tsx +++ b/packages/extension/src/confirm.tsx @@ -39,6 +39,13 @@ const holderDid = params.get("holder"); // When present, this prompt is an RP-initiated action to confirm (inbound), // not an outbound login. const action = params.get("action"); +// Session step-up (aal1 → aal2). The background sets these only after the +// offscreen has verified the signed approve-request, so `reason` — when +// present — is RP-authored prose from *inside* that signature (spec: +// "consumers MUST verify the proof BEFORE surfacing the reason"). It is +// attributed, not trusted: rendered as plain text, never markup. +const isStepUp = params.get("stepUp") === "1"; +const stepUpReason = params.get("reason"); // M5: when set, the rpDid this origin previously used. Render a // louder warning so the operator sees the swap and decides // whether to approve it. @@ -389,8 +396,21 @@ function Confirm() { }, []); const isAction = !!action; - const title = isAction ? "Confirmation request" : "Sign-in request"; - const subtitle = isAction ? ( + const title = isStepUp + ? "Step-up approval request" + : isAction + ? "Confirmation request" + : "Sign-in request"; + const subtitle = isStepUp ? ( + originHost ? ( + <> + {originHost} is asking you to + re-approve your session at a higher assurance level. + + ) : ( + <>An unknown page is requesting a session step-up. + ) + ) : isAction ? ( <> {originHost ? ( {originHost} @@ -462,6 +482,56 @@ function Confirm() { )} + {/* Step-up reason — the RP's stated purpose for wanting elevation, + pulled from INSIDE its signed approve-request (verified in the + offscreen before this window existed). Untrusted-but-attributed + prose: React renders it as text, so it cannot inject markup; the + background already capped its length and stripped control + characters, and the render cap below is belt-and-braces. Absent + reason = this card is absent and the prompt is the plain + origin/rpDid one. */} + {isStepUp && stepUpReason && ( +
+
+ Reason given by the relying party +
+
+ {stepUpReason.length > 600 ? `${stepUpReason.slice(0, 600)}…` : stepUpReason} +
+

+ Signature-verified as written by the relying party below. It is their claim — + approve only if it matches what you were doing. +

+
+ )} + {/* RP card — omitted for actions with no specific relying party. */} {rpDid && (
- +
)} diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 8e79952..dd7558b 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -19,7 +19,7 @@ import { markInboundHandled, type MediatorConnection, MediatorSessionBridge, - buildStepUpApproval, + performStepUpVta, resolveKeyAgreement, parseTaskConsentRequest, putPendingInbound, @@ -44,9 +44,6 @@ import { setDeviceWake, type SigningIdentity, signingIdentityFromSecret, - stepUpVtaFinish, - stepUpVtaStart, - verifyStepUpApproveRequest, signTrustTask, deriveSigningKeyId, forgetHolderRecord, @@ -103,6 +100,7 @@ import { OFFSCREEN_VAULT_UPSERT, OFFSCREEN_VERIFY_DID, RUNTIME_TASK_CONSENT, + RUNTIME_STEP_UP_CONSENT, RUNTIME_EMIT_WALLET_EVENT, type OffscreenDidcommLoginRequest, type OffscreenRestLoginRequest, @@ -114,6 +112,8 @@ import { type OffscreenSetWakeRequest, type OffscreenSignTrustTaskRequest, type OffscreenStepUpVtaRequest, + type RuntimeStepUpConsentRequest, + type RuntimeStepUpConsentResponse, type OffscreenVaultDeleteRequest, type OffscreenRequestTaskRequest, type OffscreenVaultListRequest, @@ -1965,67 +1965,51 @@ async function doStepUpVta( const { signing } = await loadHolder(req.params.vtaDid); sw.mark("load holder"); - // 1. RP start (REST) → the signed `auth/step-up/approve-request/0.2` - // Trust-Task document (plus legacy top-level fields for cross-checking). - const start = await stepUpVtaStart(req.params.baseUrl, req.params.accessToken); - sw.mark("rp start (challenge)"); - - // 1b. Verify BEFORE acting on anything in it. The document's proof must - // verify (eddsa-jcs-2022, assertionMethod), its issuer must be the proven - // signer, and that signer must be an executor this wallet is enrolled - // with — the spec's "consumers MUST verify the proof BEFORE surfacing the - // reason" rule, applied to everything the wallet echoes into the signed - // approve-response, not just the reason. A start response with no - // `document` is refused: the proofless legacy `{subject, sessionId, - // challenge, reason}` path was removed deliberately once the control - // plane began signing approve-requests, so its absence means an - // out-of-date (or lying) server and never a prompt. - const verified = await verifyStepUpApproveRequest(start, { - enrolledExecutorDids: await enrolledExecutorDids(req.params.vtaDid), - }); - if (!verified.ok) { - console.warn("[pnm step-up] refusing approve-request:", verified.reason); - throw new Error(`step-up approve-request refused: ${verified.reason}`); - } - // The RP the page named is the audience the approve-response will be bound - // to (`recipient: rpDid`); the approve-request's proven issuer must be that - // same party, or the wallet would be answering a question nobody it trusts - // asked. - if (verified.issuer !== req.params.rpDid) { - console.warn( - "[pnm step-up] approve-request issuer", - verified.issuer, - "does not match the page-supplied rpDid", - req.params.rpDid, - ); - throw new Error("step-up approve-request refused: issuer does not match rpDid"); - } - sw.mark("verify approve-request"); - - // 2. Wallet signs the approve-response/0.2 locally (holder-self-signs — no - // VTA round-trip). The DI proof over the subject key is the elevation - // gate. Every echoed field comes from the *verified* document's payload. - const approval = await buildStepUpApproval({ + // The flow itself — start → verify → consent → sign → finish, in that + // enforced order — lives in core (`performStepUpVta`), where it is unit + // tested. This function contributes only what core cannot know: the holder + // identity, the enrolled-executor set, and how to reach a human. The + // consent prompt fires HERE, mid-flow, via the background (only it can open + // windows): after `verifyStepUpApproveRequest` has passed — so the `reason` + // the human reads comes from inside the verified signature, per the spec's + // "consumers MUST verify the proof BEFORE surfacing the reason" — and + // before anything is signed. A refused approve-request (missing document, + // bad proof, non-enrolled signer, issuer ≠ rpDid) returns before the + // consent callback runs, so no prompt is ever raised for it; a declined + // prompt sends nothing, and the RP's challenge lapses on its TTL. + const outcome = await performStepUpVta({ + baseUrl: req.params.baseUrl, + accessToken: req.params.accessToken, signing, rpDid: req.params.rpDid, - request: verified.request, - approved: true, + enrolledExecutorDids: await enrolledExecutorDids(req.params.vtaDid), + onMark: (label) => sw.mark(label), + requestConsent: async (ctx) => { + const ask: RuntimeStepUpConsentRequest = { + type: RUNTIME_STEP_UP_CONSENT, + origin: req.origin, + rpDid: req.params.rpDid, + holderDid: signing.did, + ...(ctx.reason !== undefined ? { reason: ctx.reason } : {}), + }; + const result = (await chrome.runtime.sendMessage(ask)) as + | RuntimeStepUpConsentResponse + | undefined; + // Anything but an explicit true — including a vanished background or a + // malformed reply — is a denial. + return result?.approved === true; + }, }); - sw.mark("sign approval"); - - // 3. RP finish (REST) → elevated session tokens. - const tokens = await stepUpVtaFinish( - req.params.baseUrl, - req.params.accessToken, - approval, - ); - sw.mark("rp finish (elevate)"); + if (!outcome.ok) { + console.warn("[pnm step-up]", outcome.error); + return { ok: false, error: outcome.error }; + } return { ok: true, result: { - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - sessionId: tokens.sessionId, + accessToken: outcome.tokens.accessToken, + refreshToken: outcome.tokens.refreshToken, + sessionId: outcome.tokens.sessionId, holderDid: signing.did, timings: sw.marks, },