diff --git a/packages/core/src/persona/step-up.ts b/packages/core/src/persona/step-up.ts index 9abf319..c436bc9 100644 --- a/packages/core/src/persona/step-up.ts +++ b/packages/core/src/persona/step-up.ts @@ -57,6 +57,30 @@ export const DISCLOSURE_STEP_UP_REQUIRED_CODE = "persona/disclosure/present:step /** The reverse-DNS `ext` key the agent carries the disclosure's context under. */ const AUTHZ_CONTEXT_EXT_KEY = "org.openvtc.authorization-context"; +/** + * The `type` a disclosure's authorization context declares. + * + * Contexts are a shared channel — a Cierge share ask travels under the same + * `ext` key — and `type` is how a renderer tells them apart. Checking it here + * means a context for some *other* operation cannot be read as a disclosure + * and shown with a disclosure's words. + */ +const DISCLOSURE_AUTHZ_CONTEXT_TYPE = "https://openvtc.org/persona/authorization-context/0.1"; + +/** + * The `action.kind` a disclosure carries. + * + * Checked as well as `type`, because they answer different questions. `type` + * says which *producer's* vocabulary the context speaks; `kind` says which + * action within it. The persona context type carries only `disclose` today, so + * this rejects nothing yet — it is here for the second `kind` added under this + * type, which would otherwise be read as a disclosure, have its fields mined + * for `claimTypes` it never had, and be shown to the holder in a disclosure's + * words. Naming `kind` as the discriminator and then not checking it is how + * that arrives unnoticed. + */ +const DISCLOSURE_ACTION_KIND = "disclose"; + /** The agent needs a fresh approval before it will release this preview. */ export interface DisclosureStepUpRequired { kind: "stepUpRequired"; @@ -86,6 +110,9 @@ export interface DisclosureStepUpRequired { /** What the agent says this approval would release. Read only from the * verified document — the unsigned half of the refusal carries no authority. */ export interface DisclosureApprovalContext { + /** The agent's one-line account of the act — the same string it put in the + * request's `reason`, so a surface may show either without them differing. */ + summary?: string; /** Who would receive it. */ verifierDid?: string; /** The claim types that would leave. */ @@ -169,7 +196,25 @@ export async function verifyDisclosureStepUp( }; const ctx = (payload.ext?.[AUTHZ_CONTEXT_EXT_KEY] ?? {}) as Record; - if (ctx.previewId !== refusal.previewId) { + if (ctx.type !== DISCLOSURE_AUTHZ_CONTEXT_TYPE) { + return { + ok: false, + reason: `authorization context is ${String(ctx.type)}, not a disclosure`, + }; + } + + // The specifics live under `action`, keyed by `kind` — the shape every + // authorization context uses, so one renderer serves all of them. + const action = (ctx.action ?? {}) as Record; + + if (action.kind !== DISCLOSURE_ACTION_KIND) { + return { + ok: false, + reason: `authorization context action is ${String(action.kind)}, not a disclosure`, + }; + } + + if (action.previewId !== refusal.previewId) { return { ok: false, reason: @@ -178,8 +223,8 @@ export async function verifyDisclosureStepUp( }; } - const claimTypes = Array.isArray(ctx.claimTypes) - ? ctx.claimTypes.filter((t): t is string => typeof t === "string") + const claimTypes = Array.isArray(action.claimTypes) + ? action.claimTypes.filter((t): t is string => typeof t === "string") : []; return { @@ -188,8 +233,9 @@ export async function verifyDisclosureStepUp( issuer: verified.issuer, context: { claimTypes, - ...(typeof ctx.verifierDid === "string" ? { verifierDid: ctx.verifierDid } : {}), - ...(typeof ctx.purpose === "string" ? { purpose: ctx.purpose } : {}), + ...(typeof ctx.summary === "string" ? { summary: ctx.summary } : {}), + ...(typeof action.verifierDid === "string" ? { verifierDid: action.verifierDid } : {}), + ...(typeof action.purpose === "string" ? { purpose: action.purpose } : {}), }, }; } diff --git a/packages/core/tests/persona.step-up.mjs b/packages/core/tests/persona.step-up.mjs index 29c7819..5250ba8 100644 --- a/packages/core/tests/persona.step-up.mjs +++ b/packages/core/tests/persona.step-up.mjs @@ -25,6 +25,7 @@ const enrolled = { enrolledExecutorDids: [AGENT.did] }; const PREVIEW = "01J0000000000000000000000A"; const AUTHZ_EXT = "org.openvtc.authorization-context"; +const CONTEXT_TYPE = "https://openvtc.org/persona/authorization-context/0.1"; /** The agent-signed approve-request the refusal carries. */ async function approveRequest({ as = AGENT, previewId = PREVIEW, ctx = {} } = {}) { @@ -41,11 +42,16 @@ async function approveRequest({ as = AGENT, previewId = PREVIEW, ctx = {} } = {} reason: "Approve disclosing 1 fact to did:key:zVerifier", ext: { [AUTHZ_EXT]: { - operation: "persona/disclosure/present", - previewId, - verifierDid: "did:key:zVerifier", - claimTypes: ["payment.card"], - purpose: "checkout", + type: CONTEXT_TYPE, + summary: "Approve disclosing 1 fact to did:key:zVerifier", + risk: "high", + action: { + kind: "disclose", + previewId, + verifierDid: "did:key:zVerifier", + claimTypes: ["payment.card"], + purpose: "checkout", + }, ...ctx, }, }, @@ -129,6 +135,7 @@ test("what the holder is shown comes out of the signature", async () => { assert.deepEqual(res.context.claimTypes, ["payment.card"]); assert.equal(res.context.verifierDid, "did:key:zVerifier"); assert.equal(res.context.purpose, "checkout"); + assert.equal(res.context.summary, "Approve disclosing 1 fact to did:key:zVerifier"); assert.equal(res.request.challenge, "a".repeat(32)); }); @@ -152,7 +159,16 @@ test("the signed previewId must be the one the refusal named", async () => { refusal({ previewId: PREVIEW, previewRetained: true, - approveRequest: await approveRequest({ previewId: "01JSOMETHINGELSE00000000AA" }), + approveRequest: await approveRequest({ + ctx: { + action: { + kind: "disclose", + previewId: "01JSOMETHINGELSE00000000AA", + verifierDid: "did:key:zVerifier", + claimTypes: ["payment.card"], + }, + }, + }), }), ); const res = await verifyDisclosureStepUp(seen, enrolled); @@ -168,7 +184,7 @@ test("a tampered context does not survive the proof", async () => { const doc = await approveRequest(); // Add a claim type after signing — the shape of an attacker widening what // the holder believes they are approving. - doc.payload.ext[AUTHZ_EXT].claimTypes.push("gov.passport"); + doc.payload.ext[AUTHZ_EXT].action.claimTypes.push("gov.passport"); const seen = disclosureStepUpRequiredFrom( refusal({ previewId: PREVIEW, previewRetained: true, approveRequest: doc }), ); @@ -199,3 +215,54 @@ test("the approval is a signed approve-response the agent can verify", async () const proof = await verifyTrustTaskProof(approval, { expectedProofPurpose: "assertionMethod" }); assert.equal(proof.verified, true, proof.reason ?? ""); }); + +test("a context for some other operation is not read as a disclosure", async () => { + // Every authorization context travels under the same `ext` key — a Cierge + // share ask included. Without the `type` check, one of those would be shown + // to the holder in a disclosure's words, and its `action` read for claim + // types it never had. + const doc = await approveRequest({ + ctx: { + type: "https://openvtc.org/cierge/authorization-context/0.1", + action: { kind: "share", from: "finance", to: "travel" }, + }, + }); + const seen = disclosureStepUpRequiredFrom( + refusal({ previewId: PREVIEW, previewRetained: true, approveRequest: doc }), + ); + const res = await verifyDisclosureStepUp(seen, enrolled); + assert.equal(res.ok, false, "a share ask was accepted as a disclosure approval"); + assert.match(res.reason, /not a disclosure/); +}); + +test("an action of another kind under the same context type is refused", async () => { + // `type` and `kind` answer different questions — which producer's vocabulary, + // and which action within it. A second `kind` added under the persona type is + // the case this exists for: without the check it would be read as a + // disclosure, its fields mined for claim types it never had, and shown to the + // holder in a disclosure's words. + const doc = await approveRequest({ + ctx: { + action: { kind: "revoke", previewId: PREVIEW, claimTypes: ["payment.card"] }, + }, + }); + const seen = disclosureStepUpRequiredFrom( + refusal({ previewId: PREVIEW, previewRetained: true, approveRequest: doc }), + ); + const res = await verifyDisclosureStepUp(seen, enrolled); + assert.equal(res.ok, false, "an action of another kind was approved as a disclosure"); + assert.match(res.reason, /not a disclosure/); +}); + +test("an action with no kind at all is refused", async () => { + // The shape a producer that forgot the discriminator emits. Absence is not + // permission. + const doc = await approveRequest({ + ctx: { action: { previewId: PREVIEW, claimTypes: ["payment.card"] } }, + }); + const seen = disclosureStepUpRequiredFrom( + refusal({ previewId: PREVIEW, previewRetained: true, approveRequest: doc }), + ); + const res = await verifyDisclosureStepUp(seen, enrolled); + assert.equal(res.ok, false, "an action with no kind was approved as a disclosure"); +});