Skip to content

Commit 4ef360f

Browse files
authored
feat: prompt step-up consent on the VERIFIED reason, mid-flow (#104)
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 <glenn.g@affinidi.com>
1 parent a2061b3 commit 4ef360f

6 files changed

Lines changed: 539 additions & 73 deletions

File tree

packages/core/src/rp-login/step-up.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,148 @@ export interface StepUpVtaFinishResult {
287287
sessionId: string;
288288
}
289289

290+
/** What the consent surface may show the human for a step-up. Every member is
291+
* taken from *inside* the verified approve-request document (or is the
292+
* page-supplied `rpDid` after it has been checked equal to the proven
293+
* issuer) — nothing here predates verification. */
294+
export interface StepUpConsentContext {
295+
/** The proven signer of the approve-request (== the page's `rpDid`). */
296+
issuer: string;
297+
/** The session subject being elevated, from the verified payload. */
298+
subject: string;
299+
/** The RP session being elevated, from the verified payload. */
300+
sessionId: string;
301+
/** The RP's human-readable reason, from the verified payload. Absent when
302+
* the signed document carried none — the prompt then falls back to its
303+
* origin/rpDid-only text. */
304+
reason?: string;
305+
}
306+
307+
export interface PerformStepUpVtaArgs {
308+
baseUrl: string;
309+
accessToken: string;
310+
/** The wallet's signing identity — must be the DID the RP session
311+
* authenticated as (it signs the approve-response). */
312+
signing: SigningIdentity;
313+
/** The RP DID the page claimed. The verified approve-request's issuer must
314+
* equal it, and the approve-response is audience-bound to it. */
315+
rpDid: string;
316+
/** Executors this wallet is enrolled with; the approve-request's proven
317+
* signer must be in this set. */
318+
enrolledExecutorDids: readonly string[];
319+
/**
320+
* Ask the human. Called ONLY after the signed approve-request verified —
321+
* the `reason` it receives comes from inside the signature, which is what
322+
* lets the prompt show it at all (spec: "consumers MUST verify the proof
323+
* BEFORE surfacing the reason"). Return `false` to decline: nothing is
324+
* signed and nothing is sent to the RP — the pending challenge simply
325+
* lapses server-side.
326+
*/
327+
requestConsent: (ctx: StepUpConsentContext) => Promise<boolean>;
328+
fetchFn?: typeof fetch;
329+
/** Timing hook — called as each flow step completes. */
330+
onMark?: (label: string) => void;
331+
/** Defaults to now. Injected for tests. */
332+
now?: Date;
333+
}
334+
335+
export type PerformStepUpVtaResult =
336+
| { ok: true; tokens: StepUpVtaFinishResult }
337+
| {
338+
ok: false;
339+
error: string;
340+
/** True when the human declined the prompt (as opposed to the
341+
* approve-request being refused before any prompt was shown). */
342+
declined: boolean;
343+
};
344+
345+
/**
346+
* The whole holder-side step-up flow, in its enforced order:
347+
*
348+
* 1. RP `start` (REST) → the signed `approve-request` document
349+
* 2. verify it ({@link verifyStepUpApproveRequest}) + issuer == `rpDid`
350+
* 3. `requestConsent` — the human decides on the VERIFIED reason
351+
* 4. only on approval: sign the `approve-response` and `finish` (REST)
352+
*
353+
* The consent prompt deliberately sits *inside* this function, between
354+
* verification and signing: before it, and the human would be deciding on
355+
* words nobody has authenticated; after it, and the wallet would have signed
356+
* before anyone consented. A decline sends nothing — the RP's challenge
357+
* expires on its own TTL, so the prompt must be answered within the
358+
* challenge's validity window.
359+
*/
360+
export async function performStepUpVta(
361+
args: PerformStepUpVtaArgs,
362+
): Promise<PerformStepUpVtaResult> {
363+
const mark = args.onMark ?? (() => {});
364+
const refuse = (error: string): PerformStepUpVtaResult => ({
365+
ok: false,
366+
error,
367+
declined: false,
368+
});
369+
370+
// 1. RP start (REST) → the signed `auth/step-up/approve-request/0.2`
371+
// Trust-Task document (plus legacy top-level fields for cross-checking).
372+
const start = await stepUpVtaStart(args.baseUrl, args.accessToken, args.fetchFn);
373+
mark("rp start (challenge)");
374+
375+
// 2. Verify BEFORE acting on anything in it — a start response with no
376+
// `document`, a bad proof, or a signer outside the enrolled-executor set
377+
// is refused here, and the human never sees a prompt.
378+
const verified = await verifyStepUpApproveRequest(start, {
379+
enrolledExecutorDids: args.enrolledExecutorDids,
380+
...(args.now ? { now: args.now } : {}),
381+
});
382+
if (!verified.ok) {
383+
return refuse(`step-up approve-request refused: ${verified.reason}`);
384+
}
385+
// The RP the page named is the audience the approve-response will be bound
386+
// to (`recipient: rpDid`); the approve-request's proven issuer must be that
387+
// same party, or the wallet would be answering a question nobody it trusts
388+
// asked.
389+
if (verified.issuer !== args.rpDid) {
390+
return refuse(
391+
`step-up approve-request refused: issuer ${verified.issuer} does not match the page-supplied rpDid`,
392+
);
393+
}
394+
mark("verify approve-request");
395+
396+
// 3. The human decides, on fields that came from inside the signature.
397+
const consented = await args.requestConsent({
398+
issuer: verified.issuer,
399+
subject: verified.request.subject,
400+
sessionId: verified.request.sessionId,
401+
...(typeof verified.request.reason === "string"
402+
? { reason: verified.request.reason }
403+
: {}),
404+
});
405+
if (!consented) {
406+
// Declined = nothing leaves the wallet. No denied approve-response is
407+
// sent; the RP's pending challenge lapses on its TTL.
408+
return { ok: false, error: "step-up denied by user", declined: true };
409+
}
410+
mark("user consent");
411+
412+
// 4. Sign the approve-response/0.2 locally (holder-self-signs — no VTA
413+
// round-trip). Every echoed field comes from the *verified* payload.
414+
const approval = await buildStepUpApproval({
415+
signing: args.signing,
416+
rpDid: args.rpDid,
417+
request: verified.request,
418+
approved: true,
419+
});
420+
mark("sign approval");
421+
422+
const tokens = await stepUpVtaFinish(
423+
args.baseUrl,
424+
args.accessToken,
425+
approval,
426+
args.fetchFn,
427+
);
428+
mark("rp finish (elevate)");
429+
return { ok: true, tokens };
430+
}
431+
290432
/**
291433
* Step 3 — RP finish. Submits the signed `approve-response/0.2` document and
292434
* returns the elevated session tokens. Response body is **snake_case**.

packages/core/tests/rp-login.step-up.mjs

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import assert from "node:assert/strict";
1010

1111
import {
1212
buildStepUpApproval,
13+
performStepUpVta,
1314
verifyStepUpApproveRequest,
1415
verifyTrustTaskProof,
1516
generateSigningIdentity,
@@ -194,3 +195,165 @@ test("verifyStepUpApproveRequest: a lapsed request is refused", async () => {
194195
assert.equal(res.ok, false);
195196
assert.match(res.reason, /lapsed/);
196197
});
198+
199+
// ── performStepUpVta: the whole flow, in its enforced order ──────────────────
200+
//
201+
// start → verify → CONSENT → sign → finish. The consent callback stands in
202+
// for the human: it must be shown only post-verification content (the reason
203+
// from inside the signature), a decline must send nothing to the RP, and a
204+
// refused approve-request must never reach it at all.
205+
206+
/** Mock the RP's two REST endpoints; records every request it serves. */
207+
function mockRp(startBody) {
208+
const calls = [];
209+
const fetchFn = async (url, init) => {
210+
const u = String(url);
211+
calls.push({ url: u, body: init?.body ? JSON.parse(init.body) : undefined });
212+
if (u.endsWith("/auth/step-up/vta/start")) {
213+
return new Response(JSON.stringify(startBody), {
214+
status: 200,
215+
headers: { "content-type": "application/json" },
216+
});
217+
}
218+
if (u.endsWith("/auth/step-up/vta/finish")) {
219+
return new Response(
220+
JSON.stringify({
221+
session_id: "sess-42",
222+
access_token: "elevated-access",
223+
refresh_token: "elevated-refresh",
224+
}),
225+
{ status: 200, headers: { "content-type": "application/json" } },
226+
);
227+
}
228+
return new Response("not found", { status: 404 });
229+
};
230+
return { fetchFn, calls };
231+
}
232+
233+
function flowArgs(holder, fetchFn, requestConsent) {
234+
return {
235+
baseUrl: "https://rp.example",
236+
accessToken: "aal1-token",
237+
signing: holder,
238+
rpDid: RP.did,
239+
enrolledExecutorDids: [RP.did],
240+
fetchFn,
241+
requestConsent,
242+
};
243+
}
244+
245+
test("performStepUpVta: consent sees the reason from INSIDE the signed document, then sign+finish", async () => {
246+
const holder = generateSigningIdentity();
247+
const start = await startResponse();
248+
// Tamper the unsigned top-level reason — the human must never see this copy.
249+
start.reason = "Totally harmless, click approve.";
250+
const { fetchFn, calls } = mockRp(start);
251+
252+
const seen = [];
253+
const res = await performStepUpVta(
254+
flowArgs(holder, fetchFn, async (ctx) => {
255+
seen.push(ctx);
256+
return true;
257+
}),
258+
);
259+
260+
assert.equal(res.ok, true, res.ok ? undefined : res.error);
261+
assert.equal(res.tokens.accessToken, "elevated-access");
262+
assert.equal(res.tokens.refreshToken, "elevated-refresh");
263+
assert.equal(res.tokens.sessionId, "sess-42");
264+
265+
// The prompt content is the verified payload, not the unsigned echo.
266+
assert.equal(seen.length, 1);
267+
assert.equal(seen[0].reason, "Confirm the transfer of $1,000 to ACME Corp.");
268+
assert.equal(seen[0].issuer, RP.did);
269+
assert.equal(seen[0].subject, "did:key:zSubject");
270+
assert.equal(seen[0].sessionId, "sess-42");
271+
272+
// finish carried a signed approve-response echoing only verified fields.
273+
const finish = calls.find((c) => c.url.endsWith("/finish"));
274+
assert.ok(finish, "finish was called");
275+
assert.equal(finish.body.type, APPROVE_RESPONSE_TYPE);
276+
assert.equal(finish.body.payload.decision, "approved");
277+
assert.equal(finish.body.payload.challenge, "a".repeat(32));
278+
const proofCheck = await verifyTrustTaskProof(finish.body, {
279+
expectedProofPurpose: "assertionMethod",
280+
});
281+
assert.equal(proofCheck.verified, true, proofCheck.reason);
282+
assert.equal(proofCheck.signer, holder.did);
283+
});
284+
285+
test("performStepUpVta: a document with no reason still prompts — with no reason member", async () => {
286+
const holder = generateSigningIdentity();
287+
const start = await startResponse({ unsigned: true, legacy: false });
288+
delete start.document.payload.reason; // the RP signed a payload with no reason
289+
await signTrustTask({ envelope: start.document, signing: RP });
290+
const { fetchFn } = mockRp(start);
291+
292+
const seen = [];
293+
const res = await performStepUpVta(
294+
flowArgs(holder, fetchFn, async (ctx) => {
295+
seen.push(ctx);
296+
return true;
297+
}),
298+
);
299+
assert.equal(res.ok, true, res.ok ? undefined : res.error);
300+
assert.equal(seen.length, 1);
301+
assert.equal("reason" in seen[0], false);
302+
});
303+
304+
test("performStepUpVta: declined prompt sends NOTHING to the RP", async () => {
305+
const holder = generateSigningIdentity();
306+
const { fetchFn, calls } = mockRp(await startResponse());
307+
308+
const res = await performStepUpVta(flowArgs(holder, fetchFn, async () => false));
309+
310+
assert.equal(res.ok, false);
311+
assert.equal(res.declined, true);
312+
assert.match(res.error, /denied by user/);
313+
// Only the start fetch happened — no finish, no denied approve-response.
314+
assert.deepEqual(
315+
calls.map((c) => c.url),
316+
["https://rp.example/auth/step-up/vta/start"],
317+
);
318+
});
319+
320+
test("performStepUpVta: missing document refuses WITHOUT prompting", async () => {
321+
const holder = generateSigningIdentity();
322+
const { fetchFn, calls } = mockRp(await startResponse({ withDocument: false }));
323+
324+
let prompted = false;
325+
const res = await performStepUpVta(
326+
flowArgs(holder, fetchFn, async () => {
327+
prompted = true;
328+
return true;
329+
}),
330+
);
331+
assert.equal(res.ok, false);
332+
assert.equal(res.declined, false);
333+
assert.match(res.error, /no signed approve-request document/);
334+
assert.equal(prompted, false, "no prompt for an unverifiable request");
335+
assert.equal(calls.filter((c) => c.url.endsWith("/finish")).length, 0);
336+
});
337+
338+
test("performStepUpVta: issuer ≠ page rpDid refuses WITHOUT prompting", async () => {
339+
const holder = generateSigningIdentity();
340+
// Signed by an executor the wallet IS enrolled with — but not the RP the
341+
// page named. Verification alone passes; the binding check must still stop
342+
// the flow before any human is asked.
343+
const other = generateSigningIdentity();
344+
const { fetchFn, calls } = mockRp(await startResponse({ as: other, legacy: false }));
345+
346+
let prompted = false;
347+
const res = await performStepUpVta({
348+
...flowArgs(holder, fetchFn, async () => {
349+
prompted = true;
350+
return true;
351+
}),
352+
enrolledExecutorDids: [RP.did, other.did],
353+
});
354+
assert.equal(res.ok, false);
355+
assert.equal(res.declined, false);
356+
assert.match(res.error, /does not match the page-supplied rpDid/);
357+
assert.equal(prompted, false);
358+
assert.equal(calls.filter((c) => c.url.endsWith("/finish")).length, 0);
359+
});

0 commit comments

Comments
 (0)