From e0aee7c0fe7c9ae5c60ab05d51912b0658a97c0c Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Mon, 7 Sep 2026 22:47:59 +0200 Subject: [PATCH 1/2] fix(persona): read the disclosure context from its typed shape, and check the type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with OpenVTC/verifiable-trust-infrastructure#1306, which gives the disclosure's authorization context the `{type, summary, risk, action}` shape an approver's card can render. The check that shape makes possible is the point: authorization contexts share one `ext` key, so a Cierge share ask arrives down the same path as a disclosure. Without discriminating on `type`, a correctly-signed share ask from an enrolled agent passes every other guard in this module — proof, issuer, tamper — and would be shown to the holder in a disclosure's words, its `action` mined for claim types it never had. Now it is refused, and a test drives it. `summary` is surfaced on the context too: the agent guarantees it equals the request's `reason`, so a surface may show either. No compatibility fold for the old flat shape — nothing is deployed, and the two repos cut over together. Signed-off-by: Glenn Gore --- packages/core/src/persona/step-up.ts | 35 +++++++++++++++--- packages/core/tests/persona.step-up.mjs | 49 +++++++++++++++++++++---- 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/packages/core/src/persona/step-up.ts b/packages/core/src/persona/step-up.ts index 9abf319..0017221 100644 --- a/packages/core/src/persona/step-up.ts +++ b/packages/core/src/persona/step-up.ts @@ -57,6 +57,16 @@ 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 agent needs a fresh approval before it will release this preview. */ export interface DisclosureStepUpRequired { kind: "stepUpRequired"; @@ -86,6 +96,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 +182,18 @@ 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.previewId !== refusal.previewId) { return { ok: false, reason: @@ -178,8 +202,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 +212,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..14ac385 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,22 @@ 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/); +}); From 4a31167fadb7bef15f82c0567dfb1ca420fd6124 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Tue, 8 Sep 2026 07:24:31 +0200 Subject: [PATCH 2/2] fix(persona): check action.kind, not only the context type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security review on #190 found the gap, correctly: the module's own comment names `kind` as the per-action discriminator and nothing checked it. `type` and `kind` answer different questions — which producer's vocabulary the context speaks, and which action within it. Checking one and not the other leaves the narrower confusion open. It rejects nothing today, because the persona context type carries only `disclose`; 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. Absence is not permission either — an action with no `kind` is refused, which is the shape a producer that forgot the discriminator emits. Signed-off-by: Glenn Gore --- packages/core/src/persona/step-up.ts | 21 ++++++++++++++++ packages/core/tests/persona.step-up.mjs | 32 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/packages/core/src/persona/step-up.ts b/packages/core/src/persona/step-up.ts index 0017221..c436bc9 100644 --- a/packages/core/src/persona/step-up.ts +++ b/packages/core/src/persona/step-up.ts @@ -67,6 +67,20 @@ const AUTHZ_CONTEXT_EXT_KEY = "org.openvtc.authorization-context"; */ 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"; @@ -193,6 +207,13 @@ export async function verifyDisclosureStepUp( // 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, diff --git a/packages/core/tests/persona.step-up.mjs b/packages/core/tests/persona.step-up.mjs index 14ac385..5250ba8 100644 --- a/packages/core/tests/persona.step-up.mjs +++ b/packages/core/tests/persona.step-up.mjs @@ -234,3 +234,35 @@ test("a context for some other operation is not read as a disclosure", async () 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"); +});