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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 62 additions & 3 deletions packages/core/src/persona/step-up.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,15 +144,33 @@ export type VerifyDisclosureStepUpResult =
*/
export function disclosureStepUpRequiredFrom(e: unknown): DisclosureStepUpRequired | null {
if (!(e instanceof VtaClientError)) return null;

const body = e.details as
| { code?: unknown; details?: Record<string, unknown> }
| undefined;
if (body?.code !== DISCLOSURE_STEP_UP_REQUIRED_CODE) return null;
return disclosureStepUpFrom(body?.code, body?.details);
}

const d = body.details ?? {};
/**
* The same recognition, from a refusal that was **not** thrown.
*
* A wallet that dispatches the task itself gets the agent's `code` and
* `details` as fields rather than inside an exception — the relay shape the
* console and the background use. One rule, two entry points: a second
* implementation would be the same three checks written twice, and the pair
* would drift on the third change rather than the first.
*/
export function disclosureStepUpFrom(
code: unknown,
details: unknown,
): DisclosureStepUpRequired | null {
if (code !== DISCLOSURE_STEP_UP_REQUIRED_CODE) return null;

const d = (details ?? {}) as Record<string, unknown>;
const previewId = typeof d.previewId === "string" ? d.previewId : "";
const req = d.approveRequest;
// Without a previewId there is nothing to present again; without an
// approve-request there is nothing for the holder to approve. Either way this
// is an error like any other and is better surfaced as one than half-handled.
if (!previewId || !req || typeof req !== "object") return null;

return {
Expand All @@ -163,6 +181,47 @@ export function disclosureStepUpRequiredFrom(e: unknown): DisclosureStepUpRequir
};
}

/** Payload of the `approve-response/0.3` that answers a disclosure step-up. */
export interface DisclosureApprovalPayload {
subject: string;
sessionId: string;
challenge: string;
decision: "approved" | "denied";
grantedAcr: string;
}

/**
* The approve-response payload for a verified disclosure step-up.
*
* Deliberately **not** a signed document. `buildStepUpApproval` exists for the
* did-hosting RP, which is answered outside the channels and so must carry its
* own proof. A disclosure step-up is answered by dispatching an ordinary Trust
* Task to the agent, and the channel signs every outbound document as the
* holder with `proofPurpose: "assertionMethod"` — which is exactly the gate the
* approve-response requires. Building a second proof here would duplicate or
* overwrite that one, which is the reason `provision/integration` is called out
* in this repo's guide as the case that must bypass a channel.
*
* Every echoed field comes from the **verified** request.
*/
export function disclosureApprovalPayload(
request: StepUpApproveRequest,
approved: boolean,
): DisclosureApprovalPayload {
return {
subject: request.subject,
sessionId: request.sessionId,
challenge: request.challenge,
decision: approved ? "approved" : "denied",
grantedAcr: "aal2",
};
}

/** `auth/step-up/approve-response/0.3` — the version that can be answered
* `recorded`, which is what a bound disclosure approval must be. */
export const DISCLOSURE_APPROVE_RESPONSE_TYPE =
"https://trusttasks.org/spec/auth/step-up/approve-response/0.3";

export interface VerifyDisclosureStepUpOptions {
/** The executors this wallet is enrolled with — its agent's DID. The
* approve-request's proven signer must be one of them. */
Expand Down
51 changes: 51 additions & 0 deletions packages/core/tests/persona.step-up.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import assert from "node:assert/strict";

import {
disclosureStepUpRequiredFrom,
disclosureStepUpFrom,
disclosureApprovalPayload,
verifyDisclosureStepUp,
approveDisclosureStepUp,
buildStepUpApproval,
Expand Down Expand Up @@ -315,3 +317,52 @@ test("the disclosure approval is minted as 0.3, and rp-login's is not", async ()
assert.equal(proof.verified, true, proof.reason ?? "");
}
});

test("a refusal that was not thrown is recognised the same way", () => {
// The wallet dispatches this task itself, so the agent's refusal arrives as
// `{code, details}` fields rather than inside an exception. One rule, two
// entry points — a second implementation would be the same three checks
// written twice and would drift on the third change, not the first.
const seen = disclosureStepUpFrom(DISCLOSURE_STEP_UP_REQUIRED_CODE, {
previewId: PREVIEW,
previewRetained: true,
approveRequest: { type: "x", payload: {} },
});
assert.ok(seen);
assert.equal(seen.previewId, PREVIEW);
assert.equal(seen.previewRetained, true);

// And the same strictness: without a previewId there is nothing to present
// again, without an approve-request nothing to approve.
assert.equal(disclosureStepUpFrom(DISCLOSURE_STEP_UP_REQUIRED_CODE, { previewId: PREVIEW }), null);
assert.equal(disclosureStepUpFrom("taskFailed", { previewId: PREVIEW, approveRequest: {} }), null);
assert.equal(disclosureStepUpFrom(undefined, undefined), null, "a success is not a refusal");
});

test("the approval echoes only what the verified request said", async () => {
const seen = disclosureStepUpRequiredFrom(
refusal({ previewId: PREVIEW, previewRetained: true, approveRequest: await approveRequest() }),
);
const verified = await verifyDisclosureStepUp(seen, enrolled);
assert.ok(verified.ok);

const payload = disclosureApprovalPayload(verified.request, true);
assert.equal(payload.subject, "did:key:zHolder");
assert.equal(payload.sessionId, "sess-42");
assert.equal(payload.challenge, "a".repeat(32));
assert.equal(payload.decision, "approved");

// No proof of its own, deliberately: this goes as an ordinary Trust Task and
// the channel signs it as the holder with `assertionMethod`, which IS the
// gate. A second proof here would duplicate or overwrite that one.
assert.equal("proof" in payload, false);
assert.equal("type" in payload, false, "a payload, not a document");
});

test("a denial is expressible, and says so rather than staying silent", () => {
const payload = disclosureApprovalPayload(
{ subject: "did:key:zHolder", sessionId: "s", challenge: "c".repeat(32) },
false,
);
assert.equal(payload.decision, "denied");
});
147 changes: 139 additions & 8 deletions packages/extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
// DIDComm flow: content → RUNTIME_LOGIN_DIDCOMM → consent → offscreen doc.

import { pageTaskRefusal } from "./page-task-policy.js";
import {
disclosureStepUpFrom,
type DisclosureStepUpRequired,
} from "@openvtc/pnm-core/persona";
import { IndexedDBKVStore, listPendingInbound } from "@openvtc/pnm-core";
import {
parseActiveVtaDid,
Expand Down Expand Up @@ -117,6 +121,14 @@ import {
RUNTIME_TASK_CONSENT,
CONSENT_KEEPALIVE_PORT,
RUNTIME_STEP_UP_CONSENT,
OFFSCREEN_DISCLOSURE_STEP_UP,
RUNTIME_DISCLOSURE_STEP_UP_CONSENT,
type OffscreenDisclosureStepUpRequest,
type OffscreenDisclosureStepUpResponse,
type RuntimeDisclosureStepUpConsentRequest,
type RuntimeDisclosureStepUpConsentResponse,
type RelayTaskFailure,
type DiscloseResult,
RUNTIME_STEP_UP_VTA,
RUNTIME_APPROVER_STATE,
RUNTIME_RESOLVE_AGENT_NAME,
Expand Down Expand Up @@ -863,13 +875,21 @@ const DISCLOSURE_CONSENT_PREFIX = "disclosure-consent:";
const PERSONA_PREVIEW = "https://trusttasks.org/spec/persona/disclosure/preview/1.0";
const PERSONA_PRESENT = "https://trusttasks.org/spec/persona/disclosure/present/1.0";

/** Run one persona task as the wallet, not as the page. */
/**
* Run one persona task as the wallet, not as the page.
*
* Returns the **relay** shape, not the page-facing one: this caller is the
* wallet, and the wallet has to tell one refusal from another to drive the
* step-up ceremony below (R3.7). Nothing here reaches the page — `handleDisclose`
* returns prose or the presentation, and the `code`/`details` the agent sent
* stop at this file, exactly as `handleRequestTask` narrows them off for a site.
*/
async function runPersonaTask(
active: { vtaDid: string; restBaseUrl?: string },
origin: string,
type: string,
payload: Record<string, unknown>,
): Promise<RuntimeRequestTaskResponse> {
): Promise<{ ok: true; result: unknown } | RelayTaskFailure> {
await ensureOffscreenDocument();
return (await chrome.runtime.sendMessage({
target: OFFSCREEN_TARGET,
Expand All @@ -878,7 +898,7 @@ async function runPersonaTask(
restBaseUrl: active.restBaseUrl,
origin,
params: { type, payload },
})) as RuntimeRequestTaskResponse;
})) as { ok: true; result: unknown } | RelayTaskFailure;
}

/**
Expand Down Expand Up @@ -957,12 +977,111 @@ async function handleDisclose(req: RuntimeDiscloseRequest): Promise<RuntimeDiscl
const approved = await raiseDisclosureConsent(consentId);
if (!approved) return { ok: false, error: "user declined the disclosure" };

const presented = await runPersonaTask(active.conn, req.origin, PERSONA_PRESENT, {
contextId: binding.contextId,
previewId: preview.previewId,
});
const present = async () =>
runPersonaTask(active.conn, req.origin, PERSONA_PRESENT, {
contextId: binding.contextId,
previewId: preview.previewId,
});

let presented = await present();

// `release: stepUp` — the agent wants a fresh authentication bound to THIS
// preview before it will release it (`CLAIM-TYPES.md` §3.2).
//
// Returned as a refusal rather than thrown, and answered here rather than
// handed on. A page that received "Error: stepUpRequired" would be stranded
// at the moment the holder was supposed to act, and the retry the agent
// explicitly offered — `previewRetained: true`, the same preview, once
// approved — would be discarded at the last hop.
const stepUp = disclosureStepUpFrom(
presented.ok ? undefined : presented.code,
presented.ok ? undefined : presented.details,
);
if (stepUp) {
const done = await runDisclosureStepUp(active.conn, req.origin, stepUp);
if (!done.ok) return { ok: false, error: done.error };
// The SAME preview. The refusal did not consume it, which is the whole
// reason this is retryable rather than a restart.
presented = await present();
}

if (!presented.ok) return { ok: false, error: presented.error };
return { ok: true, result: presented.result ?? {} };
return { ok: true, result: (presented.result ?? {}) as DiscloseResult };
}

/**
* Obtain the fresh approval a `release: stepUp` disclosure needs.
*
* Delegated to the offscreen document, and that is structural rather than
* stylistic: verifying the agent's approve-request resolves a DID, and DID
* resolution cannot be statically bundled into an MV3 service worker. A dynamic
* `import()` in `background.js` is the one thing CI asserts is absent, because a
* service worker cannot load one — so the verify and the signing live where
* they can, and this side contributes the only thing it uniquely can, a window
* for the human. `doStepUpVta` has exactly this shape for the same reason.
*/
async function runDisclosureStepUp(
active: { vtaDid: string; restBaseUrl?: string },
origin: string,
refusal: DisclosureStepUpRequired,
): Promise<{ ok: true } | { ok: false; error: string }> {
await ensureOffscreenDocument();
const ask: OffscreenDisclosureStepUpRequest = {
target: OFFSCREEN_TARGET,
type: OFFSCREEN_DISCLOSURE_STEP_UP,
vtaDid: active.vtaDid,
...(active.restBaseUrl !== undefined ? { restBaseUrl: active.restBaseUrl } : {}),
origin,
refusal: {
previewId: refusal.previewId,
previewRetained: refusal.previewRetained,
unverifiedApproveRequest: refusal.unverifiedApproveRequest,
},
};
const result = (await chrome.runtime.sendMessage(ask)) as
| OffscreenDisclosureStepUpResponse
| undefined;
return result ?? { ok: false, error: "the step-up ceremony returned nothing" };
}

/**
* Ask the holder to approve **one** disclosure, freshly.
*
* Two departures from `gatedConsent`, and both are the requirement rather than
* caution:
*
* - **`requestConsent` directly, so a trusted origin does not skip it.**
* `gatedConsent` returns `true` outright for an origin the holder ticked
* "remember this site" for. That is right for a login step-up and wrong
* here: `release: stepUp` exists to make the holder decide *each time*, and
* an origin-level grant that answered for them turns "each time" into
* "once per site" — the same failure as binding to the session, reached
* from a different direction.
* - **`noRemember`, so it cannot become one.** There is nothing to remember:
* the approval is bound to a single `previewId` and dies with it.
*
* The text comes from the **verified** context, never the unsigned refusal.
*/
async function handleDisclosureStepUpConsent(
req: RuntimeDisclosureStepUpConsentRequest,
): Promise<RuntimeDisclosureStepUpConsentResponse> {
const { origin, agentDid } = req;
const context = { verifierDid: req.verifierDid, claimTypes: req.claimTypes, purpose: req.purpose };
const what =
context.claimTypes.length === 1
? context.claimTypes[0]
: `${context.claimTypes.length} facts (${context.claimTypes.join(", ")})`;
const to = context.verifierDid ? ` to ${context.verifierDid}` : "";
const why = context.purpose ? ` — they say it is for: ${context.purpose}` : "";
const { approved } = await requestConsent({
origin,
rpDid: agentDid,
noRemember: true,
stepUp: true,
action: "approve this disclosure",
reason: `Release ${what}${to}${why}. This approval covers this one disclosure.`,
});
return { approved };
}

/** The persona and context this origin's stored profile entry names. */
Expand Down Expand Up @@ -2951,6 +3070,18 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true; // async sendResponse
}

if ((message as { type?: string })?.type === RUNTIME_DISCLOSURE_STEP_UP_CONSENT) {
handleDisclosureStepUpConsent(message as RuntimeDisclosureStepUpConsentRequest)
.then(sendResponse)
// A denial, for the same reason as every other prompt here: silence is
// not agreement — least of all on the surface deciding whether a card
// number leaves.
.catch(() =>
sendResponse({ approved: false } satisfies RuntimeDisclosureStepUpConsentResponse),
);
return true; // async sendResponse
}

if ((message as { type?: string })?.type === RUNTIME_MANAGER_TASK) {
// Extension pages only. A content script carries our extension id but not
// our URL, and this relay does not stop to ask a human — so the gate is the
Expand Down
Loading
Loading