From d87c1c24d9d202e46b32b617039e2127550af6d6 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Fri, 13 Nov 2026 17:39:49 +1000 Subject: [PATCH 1/2] feat!: approve a relying party's session step-up without its tokens The page opens the step-up with its own session key and hands the wallet only the relying party's signed approve-request. approveSessionStepUp verifies it, asks for consent on the verified reason, and returns the signed approve-response for the page to send; the wallet makes no request to the relying party and is given no access or refresh token. stepUpVta and core's stepUpVtaStart / stepUpVtaFinish / performStepUpVta are replaced by approveSessionStepUp and verifyRpStepUpRequest / performRpStepUp. A step-up is bound to the origin's pinned RP DID only. Signed-off-by: Glenn Gore --- packages/core/CHANGELOG.md | 18 + packages/core/src/rp-login/step-up.ts | 303 ++++----------- packages/core/tests/rp-login.step-up.mjs | 354 ++++-------------- packages/extension/demo/index.html | 27 +- packages/extension/src/approver-policy.ts | 2 +- packages/extension/src/background.ts | 64 ++-- packages/extension/src/bridge-protocol.ts | 98 ++--- packages/extension/src/content.ts | 4 +- packages/extension/src/offscreen.ts | 57 ++- packages/extension/src/origin-pin.ts | 6 +- packages/extension/src/page-task-policy.ts | 40 +- packages/extension/src/provider.ts | 19 +- .../extension/tests/approver-policy.test.mts | 4 +- .../extension/tests/page-rp-binding.test.mts | 69 ++-- .../extension/tests/page-task-policy.test.mts | 2 +- 15 files changed, 340 insertions(+), 727 deletions(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 25979655..02556b34 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -10,6 +10,24 @@ For history before this file, see `git log` on `packages/core`. ### Changed +- **BREAKING: a relying-party session step-up no longer takes the session's + tokens.** `stepUpVtaStart`, `stepUpVtaFinish` and `performStepUpVta`, which + took the session's access and refresh tokens, sent `auth/step-up/start`, + the approve-response and `auth/refresh` to the RP, and returned new tokens, + are replaced by `verifyRpStepUpRequest` and `performRpStepUp`. The page now + opens the step-up itself with the session key its login bound, and hands + the wallet only the RP's signed `approve-request/0.5`; `performRpStepUp` + verifies it (signed by the RP, addressed to and naming the holder, not + lapsed), asks for consent on the verified reason, and returns the signed + `approve-response/0.7`. The page sends it and renews the session with its + own session key. The wallet makes no request to the RP. In the extension, + `window.vtaWallet.stepUpVta` is replaced by + `approveSessionStepUp({ rpDid, approveRequest })`, which resolves with + `{ approveResponse, holderDid }` and is refused unless `rpDid` is the one + the page's origin is pinned to. The pin no longer needs a base URL for a + step-up, since the wallet contacts no one for it. Matches the did-hosting + console's step-up (affinidi-webvh-service#445). + - **`@openvtc/vti-didcomm-js` 0.16** (from 0.14). The default resolver resolves did:webs (vti-didcomm-js #38), and DIDComm/TSP key agreement picks the X25519 `keyAgreement` key past a P-256 one (#39). `did/vm-key.ts` now diff --git a/packages/core/src/rp-login/step-up.ts b/packages/core/src/rp-login/step-up.ts index e86da033..b4dc3330 100644 --- a/packages/core/src/rp-login/step-up.ts +++ b/packages/core/src/rp-login/step-up.ts @@ -1,32 +1,24 @@ -// Step-up for a did-hosting relying party, as Trust Tasks. +// Step-up for a did-hosting relying party: the holder's half. // -// Elevates an existing session to `aal2`. The holder asks the RP to start a -// step-up for its session; the RP answers with a signed approve-request bound -// to that session; the holder verifies it, asks the human, and answers with a -// signed approve-response; the RP elevates the session, and a refresh mints -// tokens at the new level. The wallet — not a third party — signs the approval: -// it is the holder re-proving control of the session subject over a fresh -// challenge. +// Elevates an existing session to `aal2`. The relying party's console opens +// the step-up itself — it sends `auth/step-up/start/0.2` signed by the session +// key its login bound — and hands the wallet only what came back: the RP's +// signed `approve-request/0.5`, a single-use challenge bound to that session +// and its subject. The wallet verifies it, asks the human, and returns its +// signed `approve-response/0.7`. The console sends the decision and renews the +// session with its own session key. // -// 1. `auth/step-up/start/0.2` → the RP's signed approve-request/0.5 -// 2. verify the reply, then the approve-request inside it -// 3. the human decides, on the verified reason -// 4. `auth/step-up/approve-response/0.7` → `elevated` -// 5. `auth/refresh/0.2` → tokens at the new level +// 1. verify the approve-request (signed by the RP, addressed to and naming +// this holder, not lapsed) +// 2. the human decides, on the verified reason +// 3. sign the approve-response (`assertionMethod`, as the holder) // -// Every document goes to the RP's Trust Task endpoint (`{base}/trust-tasks`), -// the HTTPS binding. Each is an ordinary Trust Task, so the same documents can -// travel over any transport the RP serves them on. -// -// Every reply is checked before it is read: signed by the RP (`rpDid`), of the -// request's response type, threaded to the request, addressed back to the -// holder (`decodeTrustTaskHttpReply` with `expectedSigner` and `inReplyTo`). +// The wallet is never given the session's access or refresh token, and sends +// nothing to the RP: a session key may never sign an approval, so the +// holder's signature over the RP's challenge is the one thing only the wallet +// can contribute. import type { SigningIdentity } from "../siop/self-issued.js"; -import { withFetchTimeout, isFetchTimeout, DEFAULT_FETCH_TIMEOUT_MS } from "../http/timeout-fetch.js"; -import { signTrustTask } from "../trust-tasks/sign.js"; -import { decodeTrustTaskHttpReply } from "../vta/rest-channel.js"; -import { buildTrustTask } from "../vta/trust-task.js"; import { VtaClientError } from "../vta/errors.js"; import type { TrustTask } from "../vta/protocol.js"; import { @@ -36,100 +28,17 @@ import { type StepUpApproveResponsePayload, } from "../vta/step-up.js"; -import { - TYPE_URI as STEP_UP_START, - RESPONSE_TYPE_URI as STEP_UP_START_RESPONSE, - type AuthStepUpStartPayload, - type AuthStepUpStartResponsePayload, -} from "../specs/auth/step-up/start/0.2/payload.js"; -import { - RESPONSE_TYPE_URI as APPROVE_RESPONSE_RESPONSE, - type AuthStepUpApproveResponseRelyingPartyAck, -} from "../specs/auth/step-up/approve-response/0.7/payload.js"; -import { - TYPE_URI as AUTH_REFRESH, - RESPONSE_TYPE_URI as AUTH_REFRESH_RESPONSE, - type AuthRefresh, - type AuthRefreshResponsePayload, -} from "../specs/auth/refresh/0.2/payload.js"; - export * from "../vta/step-up.js"; -/** Who the holder is talking to, and as whom. */ -export interface RpStepUpParty { - /** The RP's Trust Task base; documents are POSTed to `{baseUrl}/trust-tasks`. */ - baseUrl: string; - /** The session's current access token. The document proof authorises; the - * bearer names the session the RP reads the assurance level from. */ - accessToken: string; +export interface RpStepUpRequestArgs { + /** The RP's signed `approve-request/0.5`, exactly as the page received it + * in the RP's `auth/step-up/start/0.2` reply. Untrusted until verified. */ + approveRequest: Record | undefined; /** The wallet's signing identity — the DID the RP session authenticated as. */ signing: SigningIdentity; - /** The RP's DID: every document is addressed to it, and every reply must be - * signed by it. */ + /** The RP's DID: the approve-request must be signed by it, and the + * approve-response is addressed to it. */ rpDid: string; - fetchFn?: typeof fetch; -} - -/** POST one signed document to the RP and return its checked reply payload. */ -async function sendToRp( - party: RpStepUpParty, - doc: TrustTask, - responseType: string, - label: string, -): Promise { - const f = withFetchTimeout(party.fetchFn); - const url = `${party.baseUrl.replace(/\/+$/, "")}/trust-tasks`; - let res: Response; - try { - res = await f(url, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Bearer ${party.accessToken}`, - }, - body: JSON.stringify(doc), - }); - } catch (err) { - if (isFetchTimeout(err)) { - throw new VtaClientError( - "e.client.timeout", - `${label}: the relying party did not respond within ${DEFAULT_FETCH_TIMEOUT_MS / 1000}s`, - ); - } - throw new VtaClientError("e.client.network", (err as Error).message); - } - return decodeTrustTaskHttpReply(res, { - expectedResponseType: responseType, - operationLabel: label, - expectedSigner: party.rpDid, - inReplyTo: doc, - }); -} - -/** Build and sign one of the holder's operational requests to the RP. */ -async function operationalRequest

( - party: RpStepUpParty, - type: string, - payload: P, -): Promise> { - const doc = buildTrustTask(type, payload, { - id: `urn:uuid:${globalThis.crypto.randomUUID()}`, - issuer: party.signing.did, - recipient: party.rpDid, - }); - // `authentication`: a request is the holder's own operational message. The - // approve-response is the one attestation in this flow (`assertionMethod`). - await signTrustTask({ - envelope: doc as unknown as Record & { proof?: unknown }, - signing: party.signing, - proofPurpose: "authentication", - }); - return doc; -} - -export interface StepUpVtaStartArgs extends RpStepUpParty { - /** The RP session to elevate. */ - sessionId: string; /** Executors this wallet is enrolled with; the approve-request's proven * signer must be one of them. */ enrolledExecutorDids: readonly string[]; @@ -137,8 +46,8 @@ export interface StepUpVtaStartArgs extends RpStepUpParty { now?: Date; } -/** An approve-request that has passed every check in {@link stepUpVtaStart}. */ -export interface VerifiedStepUpStart { +/** An approve-request that has passed every check in {@link verifyRpStepUpRequest}. */ +export interface VerifiedStepUpRequest { request: StepUpApproveRequest; /** The proven signer of the approve-request — the RP. */ issuer: string; @@ -146,29 +55,23 @@ export interface VerifiedStepUpStart { } /** - * Step 1 — ask the RP to start a step-up for `sessionId`, and verify what it - * answers before anything in it is used. + * Verify the approve-request a page hands the wallet before anything in it is + * used. * * Throws unless all of these hold: - * - the reply is the RP's signed `start#response` to this request; - * - it carries an approve-request/0.5 whose own proof verifies, signed by an + * - it is an approve-request/0.5 whose own proof verifies, signed by an * enrolled executor that is the RP itself; - * - the approve-request is addressed to the holder, names the holder as its - * subject, and is bound to `sessionId` — an approve-request for another - * session is refused, never answered. + * - it is addressed to the holder and names the holder as its subject; + * - it has not lapsed. + * + * The session it is bound to is the RP's statement, inside the RP's + * signature: the wallet echoes it into the approve-response, and the RP + * accepts that only for the session it bound the challenge to. */ -export async function stepUpVtaStart(args: StepUpVtaStartArgs): Promise { - const payload: AuthStepUpStartPayload = { sessionId: args.sessionId }; - const doc = await operationalRequest(args, STEP_UP_START, payload); - const reply = await sendToRp( - args, - doc, - STEP_UP_START_RESPONSE, - "auth/step-up/start/0.2", - ); - - const approveRequest = reply.approveRequest as unknown as Record | undefined; - const verified = await verifyStepUpApproveRequest(approveRequest, { +export async function verifyRpStepUpRequest( + args: RpStepUpRequestArgs, +): Promise { + const verified = await verifyStepUpApproveRequest(args.approveRequest, { enrolledExecutorDids: args.enrolledExecutorDids, ...(args.now ? { now: args.now } : {}), }); @@ -179,15 +82,12 @@ export async function stepUpVtaStart(args: StepUpVtaStartArgs): Promise & { proof?: unknown }; -} - -/** - * Step 3 — send the signed approve-response, then renew the session so its - * tokens carry the new level. - * - * The approve-response mints nothing: the RP answers `elevated` and records the - * new level on the session, and `auth/refresh` re-reads it. Throws unless the - * RP answers `elevated` and the refresh returns an access token. - */ -export async function stepUpVtaFinish(args: StepUpVtaFinishArgs): Promise { - const ack = await sendToRp( - args, - args.approval, - APPROVE_RESPONSE_RESPONSE, - "auth/step-up/approve-response/0.7", - ); - if (ack.status !== "elevated") { - throw new VtaClientError( - "e.client.parse", - `the relying party did not elevate the session (${ack.status}${ack.reason ? `: ${ack.reason}` : ""})`, - ); - } - - const refresh: AuthRefresh = { refreshToken: args.refreshToken }; - const doc = await operationalRequest(args, AUTH_REFRESH, refresh); - const renewed = await sendToRp( - args, - doc, - AUTH_REFRESH_RESPONSE, - "auth/refresh/0.2", - ); - if (typeof renewed.tokens?.accessToken !== "string" || renewed.tokens.accessToken === "") { - throw new VtaClientError("e.client.parse", "auth/refresh/0.2: the reply carries no access token"); - } - return { - accessToken: renewed.tokens.accessToken, - // A refresh that does not rotate leaves the presented token live. - refreshToken: renewed.tokens.refreshToken ?? args.refreshToken, - sessionId: renewed.session?.id ?? args.approval.payload.sessionId, - }; -} - /** 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 @@ -267,31 +112,25 @@ export interface StepUpConsentContext { reason?: string; } -export interface PerformStepUpVtaArgs extends RpStepUpParty { - /** The session's refresh token — renewed once the session is elevated. */ - refreshToken: string; - /** The RP session to elevate. */ - sessionId: string; - /** Executors this wallet is enrolled with; the approve-request's proven - * signer must be in this set. */ - enrolledExecutorDids: readonly string[]; +export interface PerformRpStepUpArgs extends RpStepUpRequestArgs { /** * 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. + * signed, and the RP's pending challenge lapses on its TTL. */ requestConsent: (ctx: StepUpConsentContext) => Promise; /** 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 } +export type PerformRpStepUpResult = + | { + ok: true; + /** The signed approve-response/0.7, for the page to send to the RP. */ + approveResponse: TrustTask & { proof?: unknown }; + } | { ok: false; error: string; @@ -301,63 +140,51 @@ export type PerformStepUpVtaResult = }; /** - * The whole holder-side step-up flow, in its enforced order: + * The whole holder-side step-up, in its enforced order: * - * 1. `start` → the RP's signed approve-request, verified - * ({@link stepUpVtaStart}) + * 1. verify the page-supplied approve-request ({@link verifyRpStepUpRequest}) * 2. `requestConsent` — the human decides on the VERIFIED reason - * 3. only on approval: sign the approve-response and `finish` - * ({@link stepUpVtaFinish}) + * 3. only on approval: sign the approve-response * * 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. + * before anyone consented. */ -export async function performStepUpVta( - args: PerformStepUpVtaArgs, -): Promise { +export async function performRpStepUp(args: PerformRpStepUpArgs): Promise { const mark = args.onMark ?? (() => {}); - // 1. Start, and verify before acting on anything in it. A reply that is not - // the RP's signed answer, an approve-request that does not verify, or one - // for another session or subject is refused here, and the human never - // sees a prompt. - let start: VerifiedStepUpStart; + // 1. Verify before acting on anything in it. An approve-request that does + // not verify, or one for another holder or relying party, is refused + // here, and the human never sees a prompt. + let verified: VerifiedStepUpRequest; try { - start = await stepUpVtaStart(args); + verified = await verifyRpStepUpRequest(args); } catch (err) { return { ok: false, error: (err as Error).message, declined: false }; } - mark("rp start (verified approve-request)"); + mark("verify approve-request"); // 2. The human decides, on fields that came from inside the signature. const consented = await args.requestConsent({ - issuer: start.issuer, - subject: start.request.subject, - sessionId: start.request.sessionId, - ...(typeof start.request.reason === "string" ? { reason: start.request.reason } : {}), + 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"); // 3. Sign the approve-response locally (holder-self-signs). Every echoed // field comes from the *verified* payload. - const approval = await buildStepUpApproval({ + const approveResponse = await buildStepUpApproval({ signing: args.signing, rpDid: args.rpDid, - request: start.request, + request: verified.request, approved: true, }); mark("sign approval"); - - const tokens = await stepUpVtaFinish({ ...args, approval }); - mark("rp finish (elevate + refresh)"); - return { ok: true, tokens }; + return { ok: true, approveResponse }; } diff --git a/packages/core/tests/rp-login.step-up.mjs b/packages/core/tests/rp-login.step-up.mjs index da5f67ad..1fbc1868 100644 --- a/packages/core/tests/rp-login.step-up.mjs +++ b/packages/core/tests/rp-login.step-up.mjs @@ -1,11 +1,9 @@ -// Step-up against a did-hosting relying party, as Trust Tasks: -// `auth/step-up/start/0.2` → verified approve-request/0.5 → consent → -// `approve-response/0.7` → `auth/refresh/0.2`, all to `{base}/trust-tasks`. -// -// The RP here is a fake that answers the way the control plane does: every -// reply signed by the RP's key (`proofPurpose: authentication`), threaded to -// the request and addressed back to the requester; the approve-request inside -// the start reply signed the same way. +// Step-up against a did-hosting relying party, the holder's half: the page +// hands the wallet the RP's signed approve-request/0.5 (from the RP's +// `auth/step-up/start/0.2` reply, which the page obtained with its own session +// key); the wallet verifies it, asks the human, and returns its signed +// approve-response/0.7. The wallet is given no session token and sends +// nothing to the RP. import { test } from "node:test"; import assert from "node:assert/strict"; @@ -13,9 +11,8 @@ import assert from "node:assert/strict"; import { STEP_UP_APPROVE_REQUEST_TYPE, buildStepUpApproval, - performStepUpVta, - stepUpVtaFinish, - stepUpVtaStart, + performRpStepUp, + verifyRpStepUpRequest, verifyStepUpApproveRequest, verifyTrustTaskProof, generateSigningIdentity, @@ -23,17 +20,13 @@ import { } from "../dist/index.js"; const TT = "https://trusttasks.org/spec/"; -const START = `${TT}auth/step-up/start/0.2`; const APPROVE_REQUEST_05 = `${TT}auth/step-up/approve-request/0.5`; const APPROVE_REQUEST_02 = `${TT}auth/step-up/approve-request/0.2`; const APPROVE_RESPONSE_07 = `${TT}auth/step-up/approve-response/0.7`; -const REFRESH = `${TT}auth/refresh/0.2`; -const ERROR = `${TT}trust-task-error/0.3`; const RP = generateSigningIdentity(); // the control plane — enrolled const STRANGER = generateSigningIdentity(); // not enrolled const SESSION = "sess-42"; -const BASE = "https://rp.example/api"; const sign = (doc, as, proofPurpose = "authentication") => signTrustTask({ envelope: doc, signing: as, proofPurpose }); @@ -67,88 +60,6 @@ async function approveRequest( return doc; } -/** The RP's signed reply to `req`. `before` replaces members before signing. */ -async function reply(req, payload, { as = RP, unsigned = false, before = {} } = {}) { - const doc = { - id: `urn:uuid:${crypto.randomUUID()}`, - threadId: req.id, - type: `${req.type}#response`, - issuer: as.did, - recipient: req.issuer, - issuedAt: new Date().toISOString(), - payload, - ...before, - }; - if (!unsigned) await sign(doc, as); - return doc; -} - -const json = (body, status = 200) => - new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); - -/** - * A fake RP. `answer(req)` returns the reply document for each request (or a - * Response); every request is recorded with its bearer. - */ -function fakeRp(answer) { - const calls = []; - const fetchFn = async (url, init) => { - assert.equal(String(url), `${BASE}/trust-tasks`, "every document goes to {base}/trust-tasks"); - const doc = JSON.parse(init.body); - calls.push({ doc, bearer: init.headers.authorization }); - const out = await answer(doc); - return out instanceof Response ? out : json(out); - }; - return { fetchFn, calls }; -} - -const session = (holder) => ({ - id: SESSION, - subject: holder.did, - issuedAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 3600_000).toISOString(), - acr: "aal2", -}); - -/** The honest RP: start → approve-request, approve-response → elevated, - * refresh → new tokens. */ -function honestRp(holder, { request, ack, tokens } = {}) { - return fakeRp(async (req) => { - switch (req.type) { - case START: - return reply(req, { approveRequest: request ?? (await approveRequest(holder)) }); - case APPROVE_RESPONSE_07: - return reply(req, ack ?? { status: "elevated", session: session(holder) }); - case REFRESH: - return reply(req, { - session: session(holder), - tokens: tokens ?? { - accessToken: "elevated-access", - refreshToken: "elevated-refresh", - tokenType: "Bearer", - expiresIn: 900, - }, - }); - default: - throw new Error(`unexpected ${req.type}`); - } - }); -} - -const party = (holder, fetchFn) => ({ - baseUrl: BASE, - accessToken: "aal1-access", - signing: holder, - rpDid: RP.did, - fetchFn, -}); - -const startArgs = (holder, fetchFn) => ({ - ...party(holder, fetchFn), - sessionId: SESSION, - enrolledExecutorDids: [RP.did], -}); - // ── buildStepUpApproval ───────────────────────────────────────────────────── test("buildStepUpApproval: a 0.7 approval echoes the request and is the holder's attestation", async () => { @@ -255,241 +166,124 @@ test("one approve-request version for every party: the pin, and the one start/0. assert.equal(STEP_UP_APPROVE_REQUEST_TYPE, APPROVE_REQUEST_05); }); -// ── stepUpVtaStart ────────────────────────────────────────────────────────── - -test("stepUpVtaStart: sends a signed start for the session to {base}/trust-tasks and verifies the answer", async () => { - const holder = generateSigningIdentity(); - const { fetchFn, calls } = honestRp(holder); - - const start = await stepUpVtaStart(startArgs(holder, fetchFn)); - - assert.equal(start.issuer, RP.did); - assert.equal(start.request.sessionId, SESSION); - assert.equal(calls.length, 1); - const { doc, bearer } = calls[0]; - assert.equal(bearer, "Bearer aal1-access"); - assert.equal(doc.type, START); - assert.equal(doc.issuer, holder.did); - assert.equal(doc.recipient, RP.did); - assert.ok(doc.issuedAt); - assert.deepEqual(doc.payload, { sessionId: SESSION }); - const proof = await verifyTrustTaskProof(doc, { expectedProofPurpose: "authentication" }); - assert.equal(proof.verified, true, proof.reason); - assert.equal(proof.signer, holder.did); -}); - -test("stepUpVtaStart: refuses an unsigned reply", async () => { - const holder = generateSigningIdentity(); - const { fetchFn } = fakeRp(async (req) => - reply(req, { approveRequest: await approveRequest(holder) }, { unsigned: true }), - ); - await assert.rejects(stepUpVtaStart(startArgs(holder, fetchFn)), /unsigned or its proof does not verify/); -}); - -test("stepUpVtaStart: refuses a reply signed by someone other than the RP", async () => { - const holder = generateSigningIdentity(); - const { fetchFn } = fakeRp(async (req) => - reply(req, { approveRequest: await approveRequest(holder) }, { as: STRANGER }), - ); - await assert.rejects(stepUpVtaStart(startArgs(holder, fetchFn)), /is signed by/); -}); +// ── verifyRpStepUpRequest ─────────────────────────────────────────────────── -test("stepUpVtaStart: refuses a signed reply to another request", async () => { - const holder = generateSigningIdentity(); - const { fetchFn } = fakeRp(async (req) => - reply(req, { approveRequest: await approveRequest(holder) }, { before: { threadId: "urn:uuid:other" } }), - ); - await assert.rejects(stepUpVtaStart(startArgs(holder, fetchFn)), /not to this request/); -}); - -test("stepUpVtaStart: refuses a signed reply addressed to someone else", async () => { - const holder = generateSigningIdentity(); - const { fetchFn } = fakeRp(async (req) => - reply(req, { approveRequest: await approveRequest(holder) }, { before: { recipient: STRANGER.did } }), - ); - await assert.rejects(stepUpVtaStart(startArgs(holder, fetchFn)), /is addressed to/); +const args = (holder, approveRequest, over = {}) => ({ + approveRequest, + signing: holder, + rpDid: RP.did, + enrolledExecutorDids: [RP.did], + ...over, }); -test("stepUpVtaStart: refuses an approve-request bound to a different session", async () => { +test("verifyRpStepUpRequest: the RP's approve-request for this holder verifies", async () => { const holder = generateSigningIdentity(); - const { fetchFn } = honestRp(holder, { - request: await approveRequest(holder, { over: { sessionId: "someone-elses-session" } }), + const verified = await verifyRpStepUpRequest(args(holder, await approveRequest(holder))); + assert.equal(verified.issuer, RP.did); + assert.deepEqual(verified.request, { + subject: holder.did, + sessionId: SESSION, + challenge: "a".repeat(64), + reason: "Elevate this session to aal2", }); - await assert.rejects(stepUpVtaStart(startArgs(holder, fetchFn)), /bound to a different session/); }); -test("stepUpVtaStart: refuses an approve-request naming a different subject", async () => { +test("verifyRpStepUpRequest: refuses an approve-request naming a different subject", async () => { const holder = generateSigningIdentity(); const other = generateSigningIdentity(); - const { fetchFn } = honestRp(holder, { - request: await approveRequest(holder, { over: { subject: other.did } }), - }); - await assert.rejects(stepUpVtaStart(startArgs(holder, fetchFn)), /names subject/); + const doc = await approveRequest(holder, { over: { subject: other.did } }); + await assert.rejects(verifyRpStepUpRequest(args(holder, doc)), /names subject/); }); -test("stepUpVtaStart: refuses an approve-request addressed to someone else", async () => { +test("verifyRpStepUpRequest: refuses an approve-request addressed to someone else", async () => { const holder = generateSigningIdentity(); - const { fetchFn } = honestRp(holder, { - request: await approveRequest(holder, { envelope: { recipient: STRANGER.did } }), - }); - await assert.rejects(stepUpVtaStart(startArgs(holder, fetchFn)), /not this wallet/); + const doc = await approveRequest(holder, { envelope: { recipient: STRANGER.did } }); + await assert.rejects(verifyRpStepUpRequest(args(holder, doc)), /not this wallet/); }); -test("stepUpVtaStart: refuses an approve-request whose payload has lapsed", async () => { +test("verifyRpStepUpRequest: refuses an approve-request whose payload has lapsed", async () => { const holder = generateSigningIdentity(); - const { fetchFn } = honestRp(holder, { - request: await approveRequest(holder, { over: { expiresAt: new Date(Date.now() - 1000).toISOString() } }), - }); - await assert.rejects(stepUpVtaStart(startArgs(holder, fetchFn)), /lapsed/); + const doc = await approveRequest(holder, { over: { expiresAt: new Date(Date.now() - 1000).toISOString() } }); + await assert.rejects(verifyRpStepUpRequest(args(holder, doc)), /lapsed/); }); -test("stepUpVtaStart: refuses an approve-request signed by another enrolled executor", async () => { +test("verifyRpStepUpRequest: refuses an approve-request signed by another enrolled executor", async () => { const holder = generateSigningIdentity(); const other = generateSigningIdentity(); - const { fetchFn } = honestRp(holder, { request: await approveRequest(holder, { as: other }) }); + const doc = await approveRequest(holder, { as: other }); await assert.rejects( - stepUpVtaStart({ ...startArgs(holder, fetchFn), enrolledExecutorDids: [RP.did, other.did] }), + verifyRpStepUpRequest(args(holder, doc, { enrolledExecutorDids: [RP.did, other.did] })), /is not the relying party/, ); }); -test("stepUpVtaStart: a refusal keeps the RP's code", async () => { +test("verifyRpStepUpRequest: refuses a page-altered approve-request", async () => { const holder = generateSigningIdentity(); - const { fetchFn } = fakeRp(async () => - json( - { - id: "urn:uuid:e", - type: ERROR, - payload: { code: "auth/step-up/start:notNeeded", message: "already aal2", retryable: false }, - }, - 422, - ), - ); - await assert.rejects(stepUpVtaStart(startArgs(holder, fetchFn)), (err) => { - assert.equal(err.details?.code, "auth/step-up/start:notNeeded"); - return true; - }); + const doc = await approveRequest(holder); + doc.payload.sessionId = "another-session"; + await assert.rejects(verifyRpStepUpRequest(args(holder, doc)), /signature verification failed/); }); -// ── stepUpVtaFinish ───────────────────────────────────────────────────────── - -function approvalFor(holder) { - return buildStepUpApproval({ - signing: holder, - rpDid: RP.did, - request: { subject: holder.did, sessionId: SESSION, challenge: "a".repeat(64) }, - approved: true, - }); -} - -test("stepUpVtaFinish: sends the approval, then a signed refresh, and returns the new tokens", async () => { +test("verifyRpStepUpRequest: refuses no document at all", async () => { const holder = generateSigningIdentity(); - const { fetchFn, calls } = honestRp(holder); - const approval = await approvalFor(holder); - - const tokens = await stepUpVtaFinish({ ...party(holder, fetchFn), refreshToken: "aal1-refresh", approval }); - - assert.deepEqual(tokens, { - accessToken: "elevated-access", - refreshToken: "elevated-refresh", - sessionId: SESSION, - }); - assert.deepEqual(calls.map((c) => c.doc.type), [APPROVE_RESPONSE_07, REFRESH]); - assert.equal(calls[0].doc.id, approval.id); - const refresh = calls[1].doc; - assert.deepEqual(refresh.payload, { refreshToken: "aal1-refresh" }); - assert.equal(refresh.recipient, RP.did); - const proof = await verifyTrustTaskProof(refresh, { expectedProofPurpose: "authentication" }); - assert.equal(proof.verified, true, proof.reason); + await assert.rejects(verifyRpStepUpRequest(args(holder, undefined)), /no signed approve-request/); }); -test("stepUpVtaFinish: keeps the presented refresh token when the RP does not rotate it", async () => { - const holder = generateSigningIdentity(); - const { fetchFn } = honestRp(holder, { - tokens: { accessToken: "elevated-access", tokenType: "Bearer", expiresIn: 900 }, - }); - const tokens = await stepUpVtaFinish({ - ...party(holder, fetchFn), - refreshToken: "aal1-refresh", - approval: await approvalFor(holder), - }); - assert.equal(tokens.refreshToken, "aal1-refresh"); -}); +// ── performRpStepUp ───────────────────────────────────────────────────────── -test("stepUpVtaFinish: refuses an acknowledgement that elevated nothing, and does not refresh", async () => { +test("performRpStepUp: consent sees the verified reason, then a signed approve-response for the RP", async () => { const holder = generateSigningIdentity(); - const { fetchFn, calls } = honestRp(holder, { ack: { status: "rejected", reason: "challenge expired" } }); - await assert.rejects( - stepUpVtaFinish({ ...party(holder, fetchFn), refreshToken: "r", approval: await approvalFor(holder) }), - /did not elevate the session \(rejected: challenge expired\)/, - ); - assert.equal(calls.length, 1); -}); - -test("stepUpVtaFinish: refuses an unsigned acknowledgement", async () => { - const holder = generateSigningIdentity(); - const { fetchFn } = fakeRp(async (req) => reply(req, { status: "elevated" }, { unsigned: true })); - await assert.rejects( - stepUpVtaFinish({ ...party(holder, fetchFn), refreshToken: "r", approval: await approvalFor(holder) }), - /unsigned or its proof does not verify/, - ); -}); - -// ── performStepUpVta ──────────────────────────────────────────────────────── - -const flowArgs = (holder, fetchFn, requestConsent) => ({ - ...startArgs(holder, fetchFn), - refreshToken: "aal1-refresh", - requestConsent, -}); - -test("performStepUpVta: consent sees the verified reason, then approve-response and refresh", async () => { - const holder = generateSigningIdentity(); - const { fetchFn, calls } = honestRp(holder); const seen = []; - - const res = await performStepUpVta( - flowArgs(holder, fetchFn, async (ctx) => { + const res = await performRpStepUp({ + ...args(holder, await approveRequest(holder)), + requestConsent: 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.deepEqual(seen, [ { issuer: RP.did, subject: holder.did, sessionId: SESSION, reason: "Elevate this session to aal2" }, ]); - assert.deepEqual(calls.map((c) => c.doc.type), [START, APPROVE_RESPONSE_07, REFRESH]); - const approval = calls[1].doc; - assert.equal(approval.payload.challenge, "a".repeat(64)); - assert.equal(approval.payload.decision, "approved"); + const doc = res.approveResponse; + assert.equal(doc.type, APPROVE_RESPONSE_07); + assert.equal(doc.issuer, holder.did); + assert.equal(doc.recipient, RP.did); + assert.deepEqual(doc.payload, { + subject: holder.did, + sessionId: SESSION, + challenge: "a".repeat(64), + decision: "approved", + }); + const proof = await verifyTrustTaskProof(doc, { expectedProofPurpose: "assertionMethod" }); + assert.equal(proof.verified, true, proof.reason); + assert.equal(proof.signer, holder.did); }); -test("performStepUpVta: a declined prompt sends nothing after start", async () => { +test("performRpStepUp: a declined prompt signs nothing", async () => { const holder = generateSigningIdentity(); - const { fetchFn, calls } = honestRp(holder); - const res = await performStepUpVta(flowArgs(holder, fetchFn, async () => false)); + const res = await performRpStepUp({ + ...args(holder, await approveRequest(holder)), + requestConsent: async () => false, + }); assert.equal(res.ok, false); assert.equal(res.declined, true); - assert.deepEqual(calls.map((c) => c.doc.type), [START]); + assert.equal("approveResponse" in res, false); }); -test("performStepUpVta: a refused approve-request never reaches the human", async () => { +test("performRpStepUp: a refused approve-request never reaches the human", async () => { const holder = generateSigningIdentity(); - const { fetchFn, calls } = honestRp(holder, { - request: await approveRequest(holder, { over: { sessionId: "someone-elses-session" } }), - }); let prompted = false; - const res = await performStepUpVta( - flowArgs(holder, fetchFn, async () => { + const res = await performRpStepUp({ + ...args(holder, await approveRequest(holder, { as: STRANGER })), + requestConsent: async () => { prompted = true; return true; - }), - ); + }, + }); assert.equal(res.ok, false); assert.equal(res.declined, false); - assert.match(res.error, /bound to a different session/); + assert.match(res.error, /not an executor/); assert.equal(prompted, false); - assert.deepEqual(calls.map((c) => c.doc.type), [START]); }); diff --git a/packages/extension/demo/index.html b/packages/extension/demo/index.html index a30acfe8..1ddee8e4 100644 --- a/packages/extension/demo/index.html +++ b/packages/extension/demo/index.html @@ -273,23 +273,18 @@

RP → wallet · confirm

}); // ── Step-up · VTA approval ── + // The wallet no longer runs the whole step-up: the page opens it with + // the session key its login bound (`auth/step-up/start/0.2`), hands the + // wallet only the RP's signed approve-request, sends the signed + // approve-response the wallet returns, and renews the session with its + // own session key. This demo holds no session key, so it cannot open + // one; the did-hosting console is the reference caller. $("su-go").addEventListener("click", () => { - if (!baseSession) { resetLog("su"); log("su", "no base session — log in via REST or DIDComm first", "err"); return; } - const { rpDid, baseUrl, accessToken, refreshToken, sessionId } = baseSession; - return run("su", "su-go", - () => window.vtaWallet.stepUpVta({ baseUrl, rpDid, accessToken, refreshToken, sessionId }), - [ - "wallet.stepUpVta({ baseUrl, rpDid, accessToken, refreshToken, sessionId })", - "auth/step-up/start/0.1 → signed approve-request/0.3 (verified)", - "approve-response/0.5 (signed by you) → elevated", - "auth/refresh/0.1 → aal2 tokens", - ], - (r) => { - // Replace the stashed base session with the elevated one so the - // sensitive-op check uses the aal2 token. - baseSession = { ...baseSession, accessToken: r.accessToken, refreshToken: r.refreshToken }; - }, - ); + resetLog("su"); + log("su", "page: auth/step-up/start/0.2 (signed by the page's session key) → the RP's signed approve-request/0.5"); + log("su", "wallet.approveSessionStepUp({ rpDid, approveRequest }) → signed approve-response/0.7"); + log("su", "page: sends the approve-response → elevated; auth/refresh/0.2 (session key) → aal2 tokens"); + log("su", "This demo has no session key to open a step-up with — use the did-hosting console.", "err"); }); // ── Call a sensitive op (gated on aal2) ── diff --git a/packages/extension/src/approver-policy.ts b/packages/extension/src/approver-policy.ts index 7ade54de..cd500ebe 100644 --- a/packages/extension/src/approver-policy.ts +++ b/packages/extension/src/approver-policy.ts @@ -44,7 +44,7 @@ export const APPROVE_RESPONSE = APPROVER_RESPONSE_TYPE; /** Any version of approve-response. Used only to *route* a document claiming * `approverSigned` evidence to `approverResponseRefusal`, which then refuses a * version other than {@link APPROVE_RESPONSE} by name — instead of letting it - * fall through to `pageSignRefusal`, whose reason (use `stepUpVta`) is wrong + * fall through to `pageSignRefusal`, whose reason (use `approveSessionStepUp`) is wrong * for this caller. */ const APPROVE_RESPONSE_FAMILY = "https://trusttasks.org/spec/auth/step-up/approve-response/"; diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 98aca6e9..7aa47223 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -119,7 +119,7 @@ import { OFFSCREEN_ONBOARD_PREPARE, OFFSCREEN_SIGN_TRUST_TASK, OFFSCREEN_START_INBOUND, - OFFSCREEN_STEP_UP_VTA, + OFFSCREEN_APPROVE_SESSION_STEP_UP, OFFSCREEN_TARGET, OFFSCREEN_VAULT_DELETE, OFFSCREEN_VAULT_LIST, @@ -232,7 +232,7 @@ import { type RuntimeDisclosureStepUpConsentResponse, type RelayTaskFailure, type DiscloseResult, - RUNTIME_STEP_UP_VTA, + RUNTIME_APPROVE_SESSION_STEP_UP, RUNTIME_APPROVER_STATE, RUNTIME_RESOLVE_AGENT_NAME, RUNTIME_VERIFY_RP_DID, @@ -243,7 +243,7 @@ import { type RuntimeRunDiagnosticsResponse, type OffscreenDidcommLoginRequest, type OffscreenSetWakeResponse, - type OffscreenStepUpVtaRequest, + type OffscreenApproveSessionStepUpRequest, type RuntimeApiGetRequest, type RuntimeApiGetResponse, type RuntimeApiPostRequest, @@ -283,7 +283,8 @@ import { type RuntimeSignTrustTaskResponse, type RuntimeStepUpConsentRequest, type RuntimeStepUpConsentResponse, - type RuntimeStepUpVtaRequest, + type RuntimeApproveSessionStepUpRequest, + type RuntimeApproveSessionStepUpResponse, type RuntimeVaultDeleteRequest, type RuntimeVaultDeleteResponse, type RuntimeVaultListRequest, @@ -1553,7 +1554,7 @@ async function handleDisclose(req: RuntimeDiscloseRequest): Promise { +async function handleApproveSessionStepUp( + req: RuntimeApproveSessionStepUpRequest, +): Promise { // 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" }; // NO consent prompt here. The step-up prompt fires mid-flow instead: the - // offscreen sends `auth/step-up/start`, verifies the RP's signed reply and - // the approve-request in it (proof + enrolled-executor signer + issuer == - // rpDid + bound to this session), 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. + // offscreen verifies the approve-request the page handed in (proof + + // enrolled-executor signer + issuer == rpDid + addressed to and naming this + // holder), and only then asks back via RUNTIME_STEP_UP_CONSENT — so the + // prompt can show the human the VERIFIED `reason` from inside the + // signature (the spec's rule is verify-BEFORE-surfacing). Nothing is signed + // unless that prompt approves. // - // The signer is the active connection's holder — the identity the base login - // used — never one the page names. + // The page opened the step-up with its own session key and sends the + // decision itself; the wallet is given no session token and contacts no + // one. The signer is the active connection's holder — the identity the base + // login used — never one the page names. // - // Refused before anything is sent or prompted: the page may only step up at - // the RP DID and base URL its origin is pinned to by an approved login. See - // `pageStepUpBindingRefusal`. + // Refused before anything is verified or prompted: the page may only have a + // step-up approved for the RP DID its origin is pinned to by an approved + // login. See `pageStepUpBindingRefusal`. const bindingRefusal = pageStepUpBindingRefusal( req.origin, await readOriginPin(req.origin), - req.params, + req.params?.rpDid, ); if (bindingRefusal) return { ok: false, error: bindingRefusal }; const active = await readActiveConnection(); if (!active.ok) return { ok: false, error: active.error }; await ensureOffscreenDocument(); - const offscreenRequest: OffscreenStepUpVtaRequest = { + const offscreenRequest: OffscreenApproveSessionStepUpRequest = { target: OFFSCREEN_TARGET, - type: OFFSCREEN_STEP_UP_VTA, + type: OFFSCREEN_APPROVE_SESSION_STEP_UP, vtaDid: active.conn.vtaDid, params: req.params, origin: req.origin, }; - return (await chrome.runtime.sendMessage(offscreenRequest)) as RuntimeLoginResponse; + return (await chrome.runtime.sendMessage( + offscreenRequest, + )) as RuntimeApproveSessionStepUpResponse; } /** Longest RP-authored reason the consent prompt will carry. Anything past @@ -2076,7 +2078,7 @@ async function handleStepUpConsent( // for the prompt, and a pin that changed underneath the flow is a denial, // never a prompt for a pairing the human has not confirmed. const pin = await readOriginPin(req.origin); - if (pageStepUpBindingRefusal(req.origin, pin, { rpDid: req.rpDid, baseUrl: req.baseUrl })) { + if (pageStepUpBindingRefusal(req.origin, pin, req.rpDid)) { return { approved: false }; } const approved = await gatedConsent({ @@ -4792,8 +4794,8 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; // async sendResponse } - if ((message as { type?: string })?.type === RUNTIME_STEP_UP_VTA) { - handleStepUpVta(message as RuntimeStepUpVtaRequest) + if ((message as { type?: string })?.type === RUNTIME_APPROVE_SESSION_STEP_UP) { + handleApproveSessionStepUp(message as RuntimeApproveSessionStepUpRequest) .then(sendResponse) .catch((e: unknown) => sendResponse({ ok: false, error: e instanceof Error ? e.message : String(e) }), diff --git a/packages/extension/src/bridge-protocol.ts b/packages/extension/src/bridge-protocol.ts index 4f965d16..8210c305 100644 --- a/packages/extension/src/bridge-protocol.ts +++ b/packages/extension/src/bridge-protocol.ts @@ -21,13 +21,14 @@ export const INPAGE_SOURCE = "vta-wallet/inpage" as const; export const CONTENT_SOURCE = "vta-wallet/content" as const; /** RP-callable wallet methods. `login` = REST SIOPv2; `loginDidcomm` = - * DIDComm authcrypt-sender auth; `stepUpVta` = VTA-approval step-up; + * DIDComm authcrypt-sender auth; `approveSessionStepUp` = sign the holder's + * approval of an RP's session step-up; * `apiGet` = authenticated GET proxied through the wallet (avoids the * page's cross-origin CORS block). */ export type BridgeMethod = | "login" | "loginDidcomm" - | "stepUpVta" + | "approveSessionStepUp" | "apiGet" | "apiPost" | "mediatorStatus" @@ -88,24 +89,29 @@ export interface DidcommLoginParams { scope?: string[]; } -/** Parameters for `window.vtaWallet.stepUpVta(...)`: raise an existing RP - * session to `aal2`. The wallet sends `auth/step-up/start/0.2`, verifies the - * RP's signed reply and the approve-request inside it, asks the human, sends - * a signed `approve-response/0.7`, and renews the session with - * `auth/refresh/0.2` — all to `{baseUrl}/trust-tasks`. The holder that signs - * is the active connection's; the page does not choose it. */ -export interface StepUpVtaParams { - /** The RP's Trust Task base (the same one used for the base login). */ - baseUrl: string; - /** The RP's DID — every document is addressed to it, and every reply and the - * approve-request must be signed by it. */ +/** Parameters for `window.vtaWallet.approveSessionStepUp(...)`: approve the + * step-up of an existing RP session to `aal2`. The page opens the step-up + * itself (`auth/step-up/start/0.2`, signed by the session key its login + * bound) and passes what the RP answered: its signed `approve-request/0.5`, + * a single-use challenge bound to that session and its subject. The wallet + * verifies it, asks the human, and returns its signed + * `approve-response/0.7`; the page sends that and renews the session itself. + * The wallet is given no session token and contacts no one. The holder that + * signs is the active connection's; the page does not choose it. */ +export interface ApproveSessionStepUpParams { + /** The RP's DID — the approve-request must be signed by it, the + * approve-response is addressed to it, and the page's origin must be + * pinned to it. */ rpDid: string; - /** The session's current access token. */ - accessToken: string; - /** The session's refresh token, spent on the renewal once it is elevated. */ - refreshToken: string; - /** The session to elevate. The approve-request must be bound to it. */ - sessionId: string; + /** The RP's signed `approve-request/0.5`, as the page received it. */ + approveRequest: Record; +} + +/** Result of `approveSessionStepUp`: the signed approve-response, for the + * page to send to the RP. */ +export interface ApproveSessionStepUpResult { + approveResponse: Record; + holderDid: string; } /** Parameters for `window.vtaWallet.apiGet(...)` — an authenticated GET the @@ -287,7 +293,7 @@ export interface InpageRequest { params: | LoginParams | DidcommLoginParams - | StepUpVtaParams + | ApproveSessionStepUpParams | ApiGetParams | ApiPostParams | ProxyLoginParams @@ -404,7 +410,7 @@ export interface RuntimeNotifyRequest { export const RUNTIME_LOGIN = "vta-wallet/login" as const; export const RUNTIME_LOGIN_DIDCOMM = "vta-wallet/login-didcomm" as const; -export const RUNTIME_STEP_UP_VTA = "vta-wallet/step-up-vta" as const; +export const RUNTIME_APPROVE_SESSION_STEP_UP = "vta-wallet/approve-session-step-up" as const; export const RUNTIME_API_GET = "vta-wallet/api-get" as const; export const RUNTIME_API_POST = "vta-wallet/api-post" as const; export const RUNTIME_MEDIATOR_STATUS = "vta-wallet/mediator-status" as const; @@ -499,12 +505,12 @@ export const RUNTIME_TASK_CONSENT = "vta-wallet/task-consent" as const; * keeps it alive across a decision that waits on a human. */ export const CONSENT_KEEPALIVE_PORT = "pnm/consent-keepalive" 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 + * human must now decide. Fired mid-flow — after `verifyStepUpApproveRequest` + * passed on the approve-request the page handed in, 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. */ + * anything but `approved: true` means nothing is signed. */ 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 @@ -540,13 +546,18 @@ export interface RuntimeLoginDidcommRequest { origin: string; } -/** content → background: perform a VTA-approval step-up for the calling page. */ -export interface RuntimeStepUpVtaRequest { - type: typeof RUNTIME_STEP_UP_VTA; - params: StepUpVtaParams; +/** content → background: approve a session step-up for the calling page. */ +export interface RuntimeApproveSessionStepUpRequest { + type: typeof RUNTIME_APPROVE_SESSION_STEP_UP; + params: ApproveSessionStepUpParams; origin: string; } +/** background → content for an `approveSessionStepUp` (sendResponse). */ +export type RuntimeApproveSessionStepUpResponse = + | { ok: true; result: ApproveSessionStepUpResult } + | { ok: false; error: string; code?: PageErrorCode }; + /** content → background: an authenticated GET proxied through the wallet. */ export interface RuntimeApiGetRequest { type: typeof RUNTIME_API_GET; @@ -1661,7 +1672,7 @@ export interface RuntimeVaultListPageRequest { export const OFFSCREEN_TARGET = "offscreen" as const; export const OFFSCREEN_DIDCOMM_LOGIN = "offscreen/didcomm-login" as const; -export const OFFSCREEN_STEP_UP_VTA = "offscreen/step-up-vta" as const; +export const OFFSCREEN_APPROVE_SESSION_STEP_UP = "offscreen/approve-session-step-up" as const; /** background → offscreen: flush the WebAuthn-PRF derived key * cache in the offscreen JS context. Fire-and-forget. */ export const OFFSCREEN_LOCK_WALLET = "offscreen/lock-wallet" as const; @@ -2039,10 +2050,6 @@ export interface OffscreenRestLoginRequest { restBaseUrl?: string; } -/** background → offscreen: run a VTA-approval step-up. Reply is a - * [`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. * @@ -2052,7 +2059,7 @@ export interface OffscreenRestLoginRequest { * `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. + * window for the human. Exactly the shape `OFFSCREEN_APPROVE_SESSION_STEP_UP` already has. * * Reply is an [`OffscreenDisclosureStepUpResponse`]. */ export const OFFSCREEN_DISCLOSURE_STEP_UP = "pnm/offscreen-disclosure-step-up" as const; @@ -2106,13 +2113,18 @@ export interface RuntimeDisclosureStepUpConsentResponse { approved: boolean; } -export interface OffscreenStepUpVtaRequest { +/** background → offscreen: approve a session step-up. Reply is a + * [`RuntimeApproveSessionStepUpResponse`] 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 OffscreenApproveSessionStepUpRequest { target: typeof OFFSCREEN_TARGET; - type: typeof OFFSCREEN_STEP_UP_VTA; + type: typeof OFFSCREEN_APPROVE_SESSION_STEP_UP; /** The active connection's VTA — whose holder signs. Set by the background, * never by the page. */ vtaDid: string; - params: StepUpVtaParams; + params: ApproveSessionStepUpParams; /** 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; @@ -2124,13 +2136,11 @@ export interface OffscreenStepUpVtaRequest { * 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`]. */ + /** The RP page's origin, echoed from the [`OffscreenApproveSessionStepUpRequest`]. */ origin: string; - /** The RP DID (== the approve-request's proven issuer). */ + /** The RP DID (== the approve-request's proven issuer), re-checked against + * the origin's pin before the prompt is raised. */ rpDid: string; - /** The RP's Trust Task base the flow is running against, re-checked - * against the origin's pin before the prompt is raised. */ - baseUrl: string; /** The holder DID that will sign the approve-response. */ holderDid: string; /** The RP's reason from inside the verified document. Absent when the @@ -3039,7 +3049,7 @@ export type RuntimeNotifiedElectionResponse = export const PAGE_FACING_RUNTIME_TYPES = [ RUNTIME_LOGIN, RUNTIME_LOGIN_DIDCOMM, - RUNTIME_STEP_UP_VTA, + RUNTIME_APPROVE_SESSION_STEP_UP, RUNTIME_API_GET, RUNTIME_API_POST, RUNTIME_MEDIATOR_STATUS, diff --git a/packages/extension/src/content.ts b/packages/extension/src/content.ts index 770a2d76..dc0b973b 100644 --- a/packages/extension/src/content.ts +++ b/packages/extension/src/content.ts @@ -32,7 +32,7 @@ const INPAGE_SOURCE = "vta-wallet/inpage"; const CONTENT_SOURCE = "vta-wallet/content"; const RUNTIME_LOGIN = "vta-wallet/login"; const RUNTIME_LOGIN_DIDCOMM = "vta-wallet/login-didcomm"; -const RUNTIME_STEP_UP_VTA = "vta-wallet/step-up-vta"; +const RUNTIME_APPROVE_SESSION_STEP_UP = "vta-wallet/approve-session-step-up"; const RUNTIME_API_GET = "vta-wallet/api-get"; const RUNTIME_API_POST = "vta-wallet/api-post"; const RUNTIME_MEDIATOR_STATUS = "vta-wallet/mediator-status"; @@ -78,7 +78,7 @@ injectProvider(); const RUNTIME_TYPE_BY_METHOD: Record = { login: RUNTIME_LOGIN, loginDidcomm: RUNTIME_LOGIN_DIDCOMM, - stepUpVta: RUNTIME_STEP_UP_VTA, + approveSessionStepUp: RUNTIME_APPROVE_SESSION_STEP_UP, apiGet: RUNTIME_API_GET, apiPost: RUNTIME_API_POST, mediatorStatus: RUNTIME_MEDIATOR_STATUS, diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 0550dcac..5b1f69ad 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -21,7 +21,7 @@ import { claimInboundDocument, type MediatorConnection, MediatorSessionBridge, - performStepUpVta, + performRpStepUp, resolveKeyAgreement, parseTaskConsentRequest, putPendingInbound, @@ -154,7 +154,7 @@ import { OFFSCREEN_STEP_UP_APPROVER, type OffscreenStepUpApproverRequest, OFFSCREEN_START_INBOUND, - OFFSCREEN_STEP_UP_VTA, + OFFSCREEN_APPROVE_SESSION_STEP_UP, OFFSCREEN_DISCLOSURE_STEP_UP, RUNTIME_DISCLOSURE_STEP_UP_CONSENT, type OffscreenDisclosureStepUpRequest, @@ -185,7 +185,8 @@ import { type OffscreenUnlockPrfRequest, type OffscreenSetWakeRequest, type OffscreenSignTrustTaskRequest, - type OffscreenStepUpVtaRequest, + type OffscreenApproveSessionStepUpRequest, + type RuntimeApproveSessionStepUpResponse, type RuntimeStepUpConsentRequest, type RuntimeStepUpConsentResponse, type OffscreenVaultDeleteRequest, @@ -436,8 +437,8 @@ chrome.runtime.onMessage.addListener((message, sender, respond) => { ); return true; // async sendResponse } - if (msg.type === OFFSCREEN_STEP_UP_VTA) { - doStepUpVta(message as OffscreenStepUpVtaRequest) + if (msg.type === OFFSCREEN_APPROVE_SESSION_STEP_UP) { + doApproveSessionStepUp(message as OffscreenApproveSessionStepUpRequest) .then(sendResponse) .catch((e: unknown) => sendResponse({ ok: false, error: e instanceof Error ? e.message : String(e) }), @@ -4801,7 +4802,7 @@ async function doDidcommLogin( /** * Obtain the fresh approval a `release: stepUp` disclosure needs. * - * The same enforced order as `doStepUpVta`: **verify, then show, then sign.** + * The same enforced order as `doApproveSessionStepUp`: **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 @@ -4863,32 +4864,30 @@ async function doDisclosureStepUp( return { ok: true }; } -async function doStepUpVta( - req: OffscreenStepUpVtaRequest, -): Promise { +async function doApproveSessionStepUp( + req: OffscreenApproveSessionStepUpRequest, +): Promise { // Same IndexedDB-backed holder the popup/background use, so the DID is // identical to the base-login path being elevated. const sw = createStopwatch(); const { signing } = await loadHolder(req.vtaDid); sw.mark("load holder"); - // The flow itself — start → verify → consent → sign → finish → refresh, 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 (unsigned reply, bad - // proof, non-enrolled signer, issuer ≠ rpDid, another session) 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, - refreshToken: req.params.refreshToken, - sessionId: req.params.sessionId, + // The flow itself — verify → consent → sign, in that enforced order — lives + // in core (`performRpStepUp`), 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 on the approve-request the page + // handed in — 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 (bad proof, non-enrolled signer, issuer ≠ rpDid, another + // holder) returns before the consent callback runs, so no prompt is ever + // raised for it; a declined prompt signs nothing, and the RP's challenge + // lapses on its TTL. The page sends the signed approve-response itself. + const outcome = await performRpStepUp({ + approveRequest: req.params.approveRequest, signing, rpDid: req.params.rpDid, enrolledExecutorDids: await enrolledExecutorDids(req.vtaDid), @@ -4898,7 +4897,6 @@ async function doStepUpVta( type: RUNTIME_STEP_UP_CONSENT, origin: req.origin, rpDid: req.params.rpDid, - baseUrl: req.params.baseUrl, holderDid: signing.did, ...(ctx.reason !== undefined ? { reason: ctx.reason } : {}), }; @@ -4917,11 +4915,8 @@ async function doStepUpVta( return { ok: true, result: { - accessToken: outcome.tokens.accessToken, - refreshToken: outcome.tokens.refreshToken, - sessionId: outcome.tokens.sessionId, + approveResponse: outcome.approveResponse as unknown as Record, holderDid: signing.did, - timings: sw.marks, }, }; } diff --git a/packages/extension/src/origin-pin.ts b/packages/extension/src/origin-pin.ts index e423ad1b..5526f259 100644 --- a/packages/extension/src/origin-pin.ts +++ b/packages/extension/src/origin-pin.ts @@ -14,8 +14,8 @@ * * The pin is also what bounds the page's *other* uses of the holder * key. `signTrustTask` signs only documents addressed to the pinned - * `rpDid`, and `stepUpVta` runs only against the pinned `rpDid` and - * the `baseUrl` the REST login was approved for — see + * `rpDid`, and `approveSessionStepUp` answers only an approve-request for + * the pinned `rpDid` — see * `pageSignBindingRefusal` / `pageStepUpBindingRefusal` in * `page-task-policy.ts`. Neither of those paths ever writes a pin: the * only place one is seeded is a login the human approved. @@ -65,7 +65,7 @@ export interface OriginPinStatus { /** * `true` when a base URL is pinned AND the current login names a * *different* one. Treated like an RP change: always re-prompt, loudly, - * because the pinned base URL is where `stepUpVta` is allowed to go. + * because the pinned base URL is where the approved REST login went. */ baseUrlChanged: boolean; } diff --git a/packages/extension/src/page-task-policy.ts b/packages/extension/src/page-task-policy.ts index 3a13c6fa..128ce7cd 100644 --- a/packages/extension/src/page-task-policy.ts +++ b/packages/extension/src/page-task-policy.ts @@ -241,7 +241,7 @@ export function pageSignRefusal(envelope: unknown, ownAgentDid: string): string return `${type} is addressed to the holder's own agent. A page asks the agent through requestTask, where the agent's policy applies; the wallet does not sign documents to it on a page's behalf.`; } if (type.startsWith(APPROVAL_PREFIX)) { - return `${type} cannot be signed for a page. A step-up approval is built only by the wallet's step-up flow (stepUpVta), which verifies the relying party's request and asks the holder first.`; + return `${type} cannot be signed for a page. A step-up approval is built only by the wallet's step-up flow (approveSessionStepUp), which verifies the relying party's request and asks the holder first.`; } if (type.startsWith(AUTHENTICATE_PREFIX)) { return `${type} cannot be signed for a page. A sign-in, and any session key it binds, goes through login(), whose prompt tells the holder what the site will be able to do.`; @@ -253,17 +253,19 @@ export function pageSignRefusal(envelope: unknown, ownAgentDid: string): string // // `pageSignRefusal` requires a recipient, but any recipient: a page the user // approved could still get a signature addressed to a *different* relying -// party, and present it there as the holder's own request. `stepUpVta` had the -// same gap — the page named the base URL and RP DID, so an approved page could +// party, and present it there as the holder's own request. A step-up has the +// same shape — the page names the RP DID, so an approved page could otherwise // steer an aal2 approval at a party the human never signed in to from it. // // The binding already exists. A login pins the browser-attested origin to the -// RP DID the human approved (`origin-pin.ts`, M5), and a REST login pins the -// base URL it went to beside it. Both page paths are held to that pin: +// RP DID the human approved (`origin-pin.ts`, M5). Both page paths are held to +// that pin: // // - `signTrustTask` signs only a document whose `recipient` is the pinned RP // DID for the requesting origin. -// - `stepUpVta` runs only against the pinned RP DID and the pinned base URL. +// - `approveSessionStepUp` answers only an approve-request for the pinned RP +// DID (and the offscreen then checks the request is signed by it). The +// wallet sends nothing anywhere for a step-up, so no base URL is involved. // - No pin means no signature and no step-up. Neither path pins on first use: // the only place a pin is seeded is a login the human approved, so the rule // stays login's rule — the human confirms the origin ↔ RP pairing once, on the @@ -327,34 +329,24 @@ export function pageSignBindingRefusal( } /** - * Why the page at `origin` may not start a step-up against `params`, or `null` - * when it may. `pin` is `readOriginPin(origin)`. + * Why the page at `origin` may not have a session step-up approved for + * `rpDid`, or `null` when it may. `pin` is `readOriginPin(origin)`. */ export function pageStepUpBindingRefusal( origin: string, pin: PageRpPin | undefined, - params: { rpDid: string; baseUrl: string }, + rpDid: unknown, ): string | null { + if (typeof rpDid !== "string" || !rpDid.startsWith("did:")) { + return "A step-up approval needs the relying party's DID as rpDid."; + } if (!pin) return unpinned(origin, "step up a session"); - if (params.rpDid !== pin.rpDid) { + if (rpDid !== pin.rpDid) { return ( - `Step-up names the relying party ${params.rpDid}, but ${origin} is pinned to ` + + `Step-up names the relying party ${rpDid}, but ${origin} is pinned to ` + `${pin.rpDid}. A page can only step up its session at the relying party it was ` + `signed in to.` ); } - if (!pin.baseUrl) { - return ( - `${origin} has no relying-party base URL pinned: its sign-in named none. Sign in ` + - `from this site with login() so the base URL step-up will use is shown and confirmed.` - ); - } - const requested = normalizeBaseUrl(params.baseUrl); - if (requested !== pin.baseUrl) { - return ( - `Step-up names the base URL ${params.baseUrl}, but ${origin} is pinned to ` + - `${pin.baseUrl}. A page can only step up against the base URL it signed in with.` - ); - } return null; } diff --git a/packages/extension/src/provider.ts b/packages/extension/src/provider.ts index 47abb28f..c195963a 100644 --- a/packages/extension/src/provider.ts +++ b/packages/extension/src/provider.ts @@ -23,7 +23,8 @@ import type { ProxyLoginParams, SignTrustTaskParams, SignTrustTaskResult, - StepUpVtaParams, + ApproveSessionStepUpParams, + ApproveSessionStepUpResult, VaultListParams, VaultListResultView, VaultProxyLoginResultView, @@ -102,10 +103,13 @@ interface VtaWallet { * click. Offer this method only behind an "Using an older wallet?" link. A * removal date will be set later. */ loginDidcomm(params: DidcommLoginParams): Promise; - /** Elevate an existing `aal1` session to `aal2` via VTA approval. Same - * result shape as `login`. Refused unless `rpDid` and `baseUrl` are the - * ones this origin's approved `login()` pinned. */ - stepUpVta(params: StepUpVtaParams): Promise; + /** Approve the step-up of an existing `aal1` session to `aal2`. The page + * opens the step-up itself, with its own session key, and passes the RP's + * signed `approve-request` it got back; the wallet verifies it, asks the + * human, and returns its signed `approve-response` for the page to send. + * The wallet is given no session token. Refused unless `rpDid` is the one + * this origin's approved `login()` pinned. */ + approveSessionStepUp(params: ApproveSessionStepUpParams): Promise; /** Perform an authenticated GET via the wallet (not subject to the * page's cross-origin CORS). Returns the status + parsed body. */ apiGet(params: ApiGetParams): Promise; @@ -468,7 +472,7 @@ function call( params: | LoginParams | DidcommLoginParams - | StepUpVtaParams + | ApproveSessionStepUpParams | ApiGetParams | ApiPostParams | SignTrustTaskParams @@ -635,7 +639,8 @@ if (!window.vtaWallet) { window.vtaWallet = { login: (params) => legacy("login", () => call("login", params)), loginDidcomm: (params) => legacy("loginDidcomm", () => call("loginDidcomm", params)), - stepUpVta: (params) => call("stepUpVta", params), + approveSessionStepUp: (params) => + call("approveSessionStepUp", params), apiGet: (params) => call("apiGet", params), apiPost: (params) => call("apiPost", params), mediatorStatus: () => call("mediatorStatus", {}), diff --git a/packages/extension/tests/approver-policy.test.mts b/packages/extension/tests/approver-policy.test.mts index a787b8f0..5e2d0767 100644 --- a/packages/extension/tests/approver-policy.test.mts +++ b/packages/extension/tests/approver-policy.test.mts @@ -98,14 +98,14 @@ test("the approve-response carrying the statement just issued is signable, as th test("an approver-signed approve-response at another version is routed here and refused by name", () => { // The defect #412 fixed: a version the wallet did not pin fell through to - // pageSignRefusal, which told the console to use stepUpVta. Routing on the + // pageSignRefusal, which told the console to use the session step-up. Routing on the // evidence keeps the next version bump from failing the same way. const other = envelope({ type: "https://trusttasks.org/spec/auth/step-up/approve-response/0.6" }); assert.equal(isApproverSignedResponse(other), true); const why = approverResponseRefusal(other, issued(), ORIGIN, SUBJECT) ?? ""; assert.match(why, /approve-response\/0\.6/); assert.ok(why.includes(APPROVE_RESPONSE), "names the version the wallet signs"); - assert.doesNotMatch(why, /stepUpVta/); + assert.doesNotMatch(why, /approveSessionStepUp/); }); test("only an approve-response claiming approverSigned evidence takes the approver route", () => { diff --git a/packages/extension/tests/page-rp-binding.test.mts b/packages/extension/tests/page-rp-binding.test.mts index 7a6aaac3..6414b6ff 100644 --- a/packages/extension/tests/page-rp-binding.test.mts +++ b/packages/extension/tests/page-rp-binding.test.mts @@ -1,12 +1,11 @@ // A page may use the holder key only toward the relying party its origin is // pinned to. // -// `signTrustTask` and `stepUpVta` both take the relying party from the page. -// Without a binding, a page the user approved could have a document signed for -// a different RP, or steer a step-up approval at one. The binding is login's -// origin → RP DID pin (plus the REST login's base URL): these pin that the -// refusals key off it, that the lookup is by exact origin, and that nothing but -// an approved login seeds it. +// `signTrustTask` and `approveSessionStepUp` both take the relying party from +// the page. Without a binding, a page the user approved could have a document +// signed for a different RP, or steer a step-up approval at one. The binding is +// login's origin → RP DID pin: these pin that the refusals key off it, that the +// lookup is by exact origin, and that nothing but an approved login seeds it. // // `chrome` is installed before the dynamic import because `origin-pin.ts` // reaches `chrome.storage.local`. @@ -92,69 +91,45 @@ test("a subdomain, lookalike, or another scheme or port has no pin of its own", assert.equal(pin, undefined, origin); assert.match(pageSignBindingRefusal(origin, pin, RP)!, /no relying party pinned/, origin); assert.match( - pageStepUpBindingRefusal(origin, pin, { rpDid: RP, baseUrl: BASE })!, + pageStepUpBindingRefusal(origin, pin, RP)!, /no relying party pinned/, origin, ); } }); -// ── stepUpVta ─────────────────────────────────────────────────────────────── +// ── approveSessionStepUp ──────────────────────────────────────────────────── -test("step-up at the pinned RP DID and base URL is allowed", async () => { +test("a step-up approval for the pinned RP DID is allowed", async () => { await pinned(); - const pin = await readOriginPin(ORIGIN); - assert.equal(pageStepUpBindingRefusal(ORIGIN, pin, { rpDid: RP, baseUrl: BASE }), null); - // Same base, spelled with a trailing slash and an upper-case host. - assert.equal( - pageStepUpBindingRefusal(ORIGIN, pin, { rpDid: RP, baseUrl: "https://API.rp.example/v1/" }), - null, - ); + assert.equal(pageStepUpBindingRefusal(ORIGIN, await readOriginPin(ORIGIN), RP), null); }); -test("step-up naming another RP DID is refused", async () => { +test("a step-up approval naming another RP DID is refused", async () => { await pinned(); - const why = pageStepUpBindingRefusal(ORIGIN, await readOriginPin(ORIGIN), { - rpDid: OTHER_RP, - baseUrl: BASE, - }); + const why = pageStepUpBindingRefusal(ORIGIN, await readOriginPin(ORIGIN), OTHER_RP); assert.ok(why!.includes(OTHER_RP) && why!.includes(RP), why!); }); -test("step-up against another base URL is refused", async () => { +test("a step-up approval needs no base URL: the wallet contacts no one for it", async () => { + reset(); + await pinOrigin(ORIGIN, RP); // a DIDComm login, which names no base URL + assert.equal(pageStepUpBindingRefusal(ORIGIN, await readOriginPin(ORIGIN), RP), null); +}); + +test("a step-up approval with no RP DID is refused", async () => { await pinned(); const pin = await readOriginPin(ORIGIN); - for (const baseUrl of [ - "https://attacker.example/v1", - "https://api.rp.example/v2", - "https://api.rp.example.attacker.com/v1", - "http://api.rp.example/v1", - "https://user@api.rp.example/v1", - "https://api.rp.example/v1?x=1", - "not a url", - ]) { - const why = pageStepUpBindingRefusal(ORIGIN, pin, { rpDid: RP, baseUrl }); - assert.match(why ?? "", /pinned to/, baseUrl); + for (const rpDid of [undefined, "", 42, "https://rp.example"]) { + assert.match(pageStepUpBindingRefusal(ORIGIN, pin, rpDid) ?? "", /needs the relying party's DID/); } }); -test("step-up is refused when the pin was seeded by a login that named no base URL", async () => { - reset(); - await pinOrigin(ORIGIN, RP); // a DIDComm login - const why = pageStepUpBindingRefusal(ORIGIN, await readOriginPin(ORIGIN), { - rpDid: RP, - baseUrl: BASE, - }); - assert.match(why!, /no relying-party base URL pinned/); -}); - test("an origin with no pin cannot step up", async () => { reset(); - const why = pageStepUpBindingRefusal(ORIGIN, await readOriginPin(ORIGIN), { - rpDid: RP, - baseUrl: BASE, - }); + const why = pageStepUpBindingRefusal(ORIGIN, await readOriginPin(ORIGIN), RP); assert.match(why!, /no relying party pinned/); + assert.equal(await readOriginPin(ORIGIN), undefined); }); // ── How login maintains the pin ───────────────────────────────────────────── diff --git a/packages/extension/tests/page-task-policy.test.mts b/packages/extension/tests/page-task-policy.test.mts index ce666dc8..59a18093 100644 --- a/packages/extension/tests/page-task-policy.test.mts +++ b/packages/extension/tests/page-task-policy.test.mts @@ -179,7 +179,7 @@ test("a page cannot have the wallet sign a step-up approval", () => { ]) { const why = pageSignRefusal({ type: t, recipient: RP }, AGENT); assert.notEqual(why, null, t); - assert.match(why, /stepUpVta/); + assert.match(why, /approveSessionStepUp/); } }); From 748e24433ea1d6680fc9edfbb53d58f6aa803a26 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Fri, 13 Nov 2026 17:42:30 +1000 Subject: [PATCH 2/2] test(core): drop the step-up start surface exemption the wallet no longer sends Signed-off-by: Glenn Gore --- packages/core/tests/task-surface.mjs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/core/tests/task-surface.mjs b/packages/core/tests/task-surface.mjs index 4cb66990..9984216f 100644 --- a/packages/core/tests/task-surface.mjs +++ b/packages/core/tests/task-surface.mjs @@ -117,12 +117,6 @@ const NOT_IN_SDK = [ "Emitted by the relying party during a step-up and consumed here. The RP side lives " + "in did-hosting, so vta-sdk carries the approve-*response* half only.", }, - { - prefix: "https://trusttasks.org/spec/auth/step-up/start/", - why: - "Served by the did-hosting control plane, the relying party a session is stepped " + - "up at (rp-login/step-up.ts). The VTA is not the counterparty.", - }, { prefix: "https://trusttasks.org/spec/auth/step-up/approver/attest/", why: