diff --git a/package-lock.json b/package-lock.json index 71012a8..e088d80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2304,9 +2304,9 @@ "link": true }, "node_modules/@openvtc/trust-tasks": { - "version": "0.17.5", - "resolved": "https://registry.npmjs.org/@openvtc/trust-tasks/-/trust-tasks-0.17.5.tgz", - "integrity": "sha512-0oeNHPsZipos4X2c9SteMsrzesgpwPr8O50fVHeIpAgKBugrO/GyGjzEL1k60aaEX8HY+ZM9mLsoMtikIBhoBQ==", + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@openvtc/trust-tasks/-/trust-tasks-0.17.6.tgz", + "integrity": "sha512-0MXk0L/TYyTXa8se2XpHVz7FTr+noFF7wRM3FVYmJwNlLtW/XXaZTlCaITdOj9i7AEuFFij+qqTPNG39o0KnsA==", "license": "Apache-2.0" }, "node_modules/@openvtc/vti-didcomm-js": { @@ -8034,7 +8034,7 @@ "dependencies": { "@cfworker/json-schema": "^4.1.1", "@noble/curves": "^2.4.0", - "@openvtc/trust-tasks": "^0.17.4", + "@openvtc/trust-tasks": "^0.17.6", "@openvtc/vti-didcomm-js": "^0.7.0", "@openvtc/vti-tsp-js": "^0.2.0", "@scure/base": "^2.2.0", diff --git a/packages/core/package.json b/packages/core/package.json index b467262..86a8575 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -126,7 +126,7 @@ "dependencies": { "@cfworker/json-schema": "^4.1.1", "@noble/curves": "^2.4.0", - "@openvtc/trust-tasks": "^0.17.4", + "@openvtc/trust-tasks": "^0.17.6", "@openvtc/vti-didcomm-js": "^0.7.0", "@openvtc/vti-tsp-js": "^0.2.0", "@scure/base": "^2.2.0", diff --git a/packages/core/src/persona/step-up.ts b/packages/core/src/persona/step-up.ts index c436bc9..27c0dbb 100644 --- a/packages/core/src/persona/step-up.ts +++ b/packages/core/src/persona/step-up.ts @@ -247,6 +247,17 @@ export async function verifyDisclosureStepUp( * only so a caller handling a disclosure never has to reach into `rp-login/` * for it. `request` must come from {@link verifyDisclosureStepUp}, never from * the refusal directly. + * + * **Minted as `0.3`, which is the whole point of this call existing.** The + * approval is bound to one `previewId`, so the honest acknowledgement is + * `recorded` — applied to that disclosure, elevating nothing. An approval sent + * as 0.2 is answered `elevated`, and the session then satisfies unrelated + * step-up gates for its window on the strength of a decision the holder made + * about a card number. + * + * The agent has accepted 0.3 since VTI #1316; it had to, before this could + * send it. Nothing else in this wallet mints 0.3 — `rp-login` answers a + * different relying party and stays on 0.2. */ export async function approveDisclosureStepUp(args: { signing: SigningIdentity; @@ -261,6 +272,7 @@ export async function approveDisclosureStepUp(args: { rpDid: args.agentDid, request: args.request, approved: args.approved, + responseVersion: "0.3", ...(args.deniedReason !== undefined ? { deniedReason: args.deniedReason } : {}), }); } diff --git a/packages/core/src/rp-login/step-up.ts b/packages/core/src/rp-login/step-up.ts index d71b1ea..6f477ce 100644 --- a/packages/core/src/rp-login/step-up.ts +++ b/packages/core/src/rp-login/step-up.ts @@ -203,13 +203,21 @@ export async function performStepUpVta( } mark("user consent"); - // 4. Sign the approve-response/0.2 locally (holder-self-signs — no VTA + // 4. Sign the approve-response locally (holder-self-signs — no VTA // round-trip). Every echoed field comes from the *verified* payload. + // + // **0.2, and it stays 0.2 until the control plane moves.** This flow + // answers the did-hosting control plane, not the VTA. `recorded` is a 0.3 + // member and the VTA is the party that learned it; sending 0.3 here would + // be refused as an unsupported type and would take out login elevation + // entirely. Nothing on this path binds an approval to a single operation, + // so there is nothing 0.3 would buy it either. const approval = await buildStepUpApproval({ signing: args.signing, rpDid: args.rpDid, request: verified.request, approved: true, + responseVersion: "0.2", }); mark("sign approval"); diff --git a/packages/core/src/vta/step-up.ts b/packages/core/src/vta/step-up.ts index 1ee6a04..60a1ddb 100644 --- a/packages/core/src/vta/step-up.ts +++ b/packages/core/src/vta/step-up.ts @@ -24,7 +24,8 @@ import { verifyTrustTaskProof } from "../trust-tasks/verify.js"; import type { SigningIdentity } from "../siop/self-issued.js"; import type { TrustTask } from "./protocol.js"; -import { TYPE_URI as MSG_APPROVE_RESPONSE } from "@openvtc/trust-tasks/auth/step-up/approve-response/0.2/payload"; +import { TYPE_URI as MSG_APPROVE_RESPONSE_0_2 } from "@openvtc/trust-tasks/auth/step-up/approve-response/0.2/payload"; +import { TYPE_URI as MSG_APPROVE_RESPONSE_0_3 } from "@openvtc/trust-tasks/auth/step-up/approve-response/0.3/payload"; import { TYPE_URI as APPROVE_REQUEST_0_2 } from "@openvtc/trust-tasks/auth/step-up/approve-request/0.2/payload"; import { TYPE_URI as APPROVE_REQUEST_0_1 } from "@openvtc/trust-tasks/auth/step-up/approve-request/0.1/payload"; @@ -193,6 +194,20 @@ export interface StepUpApproveResponsePayload { deniedReason?: string; } +/** + * Which acknowledgement vocabulary the relying party can answer in. + * + * **Not a preference — a property of the party being answered.** A response + * document's `type` is the request's `type` plus `#response`, so this decides + * what the RP is *allowed to say back*. `0.3` adds `recorded`, the honest answer + * for an approval bound to one operation rather than to the session. + * + * Sending `0.3` to a relying party that does not know it is refused as an + * unsupported type — which takes out step-up for that party entirely — so this + * is per-RP and never a global default. + */ +export type ApproveResponseVersion = "0.2" | "0.3"; + export interface BuildStepUpApprovalArgs { /** The wallet's Ed25519 signing identity — its `did` is the response * `subject`/`issuer` and its `kid` the proof's `verificationMethod`. It @@ -207,12 +222,30 @@ export interface BuildStepUpApprovalArgs { approved: boolean; /** Human-readable rationale, attached when the user denies. */ deniedReason?: string; + /** + * The version this relying party can answer in. **Required, deliberately.** + * + * This wallet speaks to two relying parties with different capabilities — the + * VTA, which accepts `0.3`, and the did-hosting control plane, which does not + * — and there is no default that is right for both. An optional field would + * make the next relying party added inherit whichever answer happened to be + * the default, and both directions of that mistake are silent: too low and + * the approval needlessly elevates a session, too high and every step-up + * against that party is refused. + * + * The same reasoning as `SigningIdentity` being a required channel input: + * a call that could omit it is a call that gets it wrong by not thinking. + */ + responseVersion: ApproveResponseVersion; } /** - * Build and sign the `auth/step-up/approve-response/0.2` Trust-Task document. - * The DI proof (`eddsa-jcs-2022`, `proofPurpose: assertionMethod`) over the - * subject key is what the RP verifies to elevate the session. + * Build and sign an `auth/step-up/approve-response` Trust-Task document, in the + * version `responseVersion` names. The DI proof (`eddsa-jcs-2022`, + * `proofPurpose: assertionMethod`) over the subject key is what the RP verifies. + * + * The payload is identical across `0.2` and `0.3`; only the acknowledgement the + * RP may return differs, which is why the version rides on the request. */ export async function buildStepUpApproval( args: BuildStepUpApprovalArgs, @@ -228,7 +261,7 @@ export async function buildStepUpApproval( const document: TrustTask & { proof?: unknown } = { id: globalThis.crypto.randomUUID(), - type: MSG_APPROVE_RESPONSE, + type: args.responseVersion === "0.3" ? MSG_APPROVE_RESPONSE_0_3 : MSG_APPROVE_RESPONSE_0_2, issuer: args.signing.did, recipient: args.rpDid, payload, diff --git a/packages/core/task-surface.json b/packages/core/task-surface.json index 9a6368c..6632f98 100644 --- a/packages/core/task-surface.json +++ b/packages/core/task-surface.json @@ -2,7 +2,7 @@ "$comment": "Generated by scripts/sync-task-surface.mjs from a vta-sdk checkout. Do not hand-edit: re-run the script. Checked by tests/task-surface.mjs.", "source": { "crate": "vta-sdk", - "version": "0.33.0", + "version": "0.34.0", "scanned": "vta-sdk/src/**/*.rs" }, "tasks": [ @@ -101,6 +101,9 @@ { "uri": "https://trusttasks.org/spec/auth/step-up/approve-response/0.2" }, + { + "uri": "https://trusttasks.org/spec/auth/step-up/approve-response/0.3" + }, { "uri": "https://trusttasks.org/spec/auth/whoami/0.1", "consts": [ @@ -406,6 +409,9 @@ { "uri": "https://trusttasks.org/spec/persona/binding/set/1.0" }, + { + "uri": "https://trusttasks.org/spec/persona/claim-types/list/1.0" + }, { "uri": "https://trusttasks.org/spec/persona/contact/delete/1.0" }, diff --git a/packages/core/tests/persona.step-up.mjs b/packages/core/tests/persona.step-up.mjs index 5250ba8..d43342d 100644 --- a/packages/core/tests/persona.step-up.mjs +++ b/packages/core/tests/persona.step-up.mjs @@ -12,6 +12,7 @@ import { disclosureStepUpRequiredFrom, verifyDisclosureStepUp, approveDisclosureStepUp, + buildStepUpApproval, DISCLOSURE_STEP_UP_REQUIRED_CODE, VtaClientError, generateSigningIdentity, @@ -266,3 +267,51 @@ test("an action with no kind at all is refused", async () => { const res = await verifyDisclosureStepUp(seen, enrolled); assert.equal(res.ok, false, "an action with no kind was approved as a disclosure"); }); + +test("the disclosure approval is minted as 0.3, and rp-login's is not", async () => { + // The split this wallet has to hold: it answers TWO relying parties with + // different capabilities. The agent accepts 0.3 (VTI #1316); the did-hosting + // control plane does not. Getting it wrong is silent in both directions — + // too low and a bound approval needlessly elevates a session, too high and + // every step-up against that party is refused as an unsupported type. + const seen = disclosureStepUpRequiredFrom( + refusal({ previewId: PREVIEW, previewRetained: true, approveRequest: await approveRequest() }), + ); + const verified = await verifyDisclosureStepUp(seen, enrolled); + assert.ok(verified.ok); + + const holder = generateSigningIdentity(); + const disclosure = await approveDisclosureStepUp({ + signing: holder, + agentDid: AGENT.did, + request: verified.request, + approved: true, + }); + assert.equal( + disclosure.type, + "https://trusttasks.org/spec/auth/step-up/approve-response/0.3", + "a bound disclosure approval must be minted 0.3, or the agent answers `elevated` and the \ + session is raised on the strength of a decision about one card number", + ); + + // The same builder, answering the other relying party, stays on 0.2. + const rp = await buildStepUpApproval({ + signing: holder, + rpDid: "did:web:rp.example", + request: verified.request, + approved: true, + responseVersion: "0.2", + }); + assert.equal( + rp.type, + "https://trusttasks.org/spec/auth/step-up/approve-response/0.2", + "the version must be per relying party, not a property of the builder", + ); + + // Both still verify — the payload is identical across the two versions, which + // is why only the acknowledgement needed a new one. + for (const doc of [disclosure, rp]) { + const proof = await verifyTrustTaskProof(doc, { expectedProofPurpose: "assertionMethod" }); + assert.equal(proof.verified, true, proof.reason ?? ""); + } +}); diff --git a/packages/core/tests/rp-login.step-up.mjs b/packages/core/tests/rp-login.step-up.mjs index 019add7..1fc3110 100644 --- a/packages/core/tests/rp-login.step-up.mjs +++ b/packages/core/tests/rp-login.step-up.mjs @@ -26,7 +26,7 @@ test("buildStepUpApproval: signed approved response echoes the request and verif const rpDid = "did:web:rp.example"; const request = { subject: holder.did, sessionId: "sess-1", challenge: "c".repeat(64) }; - const doc = await buildStepUpApproval({ signing: holder, rpDid, request, approved: true }); + const doc = await buildStepUpApproval({ signing: holder, rpDid, request, approved: true, responseVersion: "0.2" }); assert.equal(doc.type, APPROVE_RESPONSE_TYPE); assert.equal(doc.issuer, holder.did); @@ -49,6 +49,7 @@ test("buildStepUpApproval: denied response carries a signed deniedReason", async const request = { subject: holder.did, sessionId: "sess-2", challenge: "d".repeat(64) }; const doc = await buildStepUpApproval({ + responseVersion: "0.2", signing: holder, rpDid: "did:web:rp.example", request, @@ -65,7 +66,7 @@ test("buildStepUpApproval: denied response carries a signed deniedReason", async test("buildStepUpApproval: tampering the signed challenge breaks verification", async () => { const holder = generateSigningIdentity(); const request = { subject: holder.did, sessionId: "sess-3", challenge: "e".repeat(64) }; - const doc = await buildStepUpApproval({ signing: holder, rpDid: "did:web:rp.example", request, approved: true }); + const doc = await buildStepUpApproval({ signing: holder, rpDid: "did:web:rp.example", request, approved: true, responseVersion: "0.2" }); doc.payload.challenge = "f".repeat(64); // flip after signing const result = await verifyTrustTaskProof(doc);