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
56 changes: 51 additions & 5 deletions packages/core/src/persona/step-up.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -169,7 +196,25 @@ export async function verifyDisclosureStepUp(
};
const ctx = (payload.ext?.[AUTHZ_CONTEXT_EXT_KEY] ?? {}) as Record<string, unknown>;

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<string, unknown>;

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:
Expand All @@ -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 {
Expand All @@ -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 } : {}),
},
};
}
Expand Down
81 changes: 74 additions & 7 deletions packages/core/tests/persona.step-up.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {} } = {}) {
Expand All @@ -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,
},
},
Expand Down Expand Up @@ -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));
});

Expand All @@ -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);
Expand All @@ -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 }),
);
Expand Down Expand Up @@ -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");
});
Loading