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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/persona/step-up.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 } : {}),
});
}
10 changes: 9 additions & 1 deletion packages/core/src/rp-login/step-up.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
43 changes: 38 additions & 5 deletions packages/core/src/vta/step-up.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -228,7 +261,7 @@ export async function buildStepUpApproval(

const document: TrustTask<StepUpApproveResponsePayload> & { 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,
Expand Down
8 changes: 7 additions & 1 deletion packages/core/task-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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"
},
Expand Down
49 changes: 49 additions & 0 deletions packages/core/tests/persona.step-up.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
disclosureStepUpRequiredFrom,
verifyDisclosureStepUp,
approveDisclosureStepUp,
buildStepUpApproval,
DISCLOSURE_STEP_UP_REQUIRED_CODE,
VtaClientError,
generateSigningIdentity,
Expand Down Expand Up @@ -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 ?? "");
}
});
5 changes: 3 additions & 2 deletions packages/core/tests/rp-login.step-up.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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);
Expand Down
Loading