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
137 changes: 134 additions & 3 deletions packages/core/src/inbound/task-consent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@

import { packAuthcrypt, packAuthcryptJson, wrapForward, type Identity } from "../didcomm/index.js";
import type { RemoteDidcommEndpoint } from "../vta/didcomm.js";
import { TRUST_TASK_ENVELOPE_TYPE, type TrustTask } from "../vta/protocol.js";
import {
TRUST_TASK_ENVELOPE_TYPE,
isTrustTaskErrorType,
type TrustTask,
type TrustTaskErrorPayload,
} from "../vta/protocol.js";
import { signTrustTask } from "../trust-tasks/sign.js";
import { verifyTrustTaskProof } from "../trust-tasks/verify.js";
import type { SigningIdentity } from "../siop/self-issued.js";
Expand All @@ -51,6 +56,118 @@ export const TASK_CONSENT_DECISION_TYPE =
/** VTA → requester: an approval landed and a grant is ready — re-submit now. */
export const TASK_CONSENT_GRANTED_TYPE =
"https://trusttasks.org/spec/task-consent/granted/0.1";
/** The executor's acknowledgement of a decision this device sent. */
export const TASK_CONSENT_DECISION_RESPONSE_TYPE = `${TASK_CONSENT_DECISION_TYPE}#response`;

/**
* What the executor did with a decision this device sent.
*
* `accepted: false` is the case that matters. A refusal means a human was
* shown a change, agreed to it, and the agreement did not take — which is
* strictly worse than a prompt that never arrived, because the person believes
* they have acted. It has to reach them.
*/
export type TaskConsentOutcome =
| {
accepted: true;
/** `granted` = threshold met, the requester can execute. `pending` =
* recorded, more approvals needed. `denied` = the request was aborted,
* which is a successful *outcome* of a `deny`, not a failure. */
status: string;
approvals?: number;
needed?: number;
payloadDigest?: string;
/** The decision document id this answers, when the reply carried one. */
thid?: string;
}
| {
accepted: false;
/** Framework status code — snake_case in error/0.1, lowerCamelCase in
* 0.2. Opaque: log it, don't branch on a casing. */
code: string;
message?: string;
retryable: boolean;
details?: unknown;
thid?: string;
};

/**
* Parse the executor's reply to a `task-consent/decision` this device sent.
*
* Returns `null` for anything that is not such a reply — that is the only case
* a caller may ignore.
*
* ## Why this exists
*
* The executor answers a decision on the same DIDComm thread, as a Trust-Task
* envelope: a `decision/0.1#response` document on success, a
* `trust-task-error/{0.1,0.2}` on refusal. Nothing here recognised either, so
* both fell through the inbound handler's final "anything else is ignored"
* branch — no log, no surface, nothing.
*
* That is how an approval refused by the VTA looked identical, from this side,
* to one that was delivered and worked: the human approved, the wallet sent,
* the executor replied "no", and the wallet discarded the reply. The operator
* then watched the requester re-submit forever with no clue which end was at
* fault. Reading the answer is the difference between a two-minute diagnosis
* and an afternoon of packet-staring.
*
* ## What is trusted
*
* Only the authcrypt sender, and only to decide whether to *believe* the
* reply — it is diagnostic, and grants nothing. A reply whose sender is not an
* enrolled executor is dropped: an unauthenticated party must not be able to
* tell this device that its approval failed (a lie that invites the human to
* approve a second time), nor that it succeeded.
*/
export function parseTaskConsentOutcome(
message: Record<string, unknown>,
opts: { enrolledExecutorDids: readonly string[] },
): TaskConsentOutcome | null {
if (message.type !== TRUST_TASK_ENVELOPE_TYPE) return null;

// A missing `from` means the transport could not authenticate the sender.
// Unlike the `granted` nudge — which is cross-checked against a digest the
// page already holds — nothing downstream re-verifies this, so an
// unattributable reply is dropped rather than believed.
const from = typeof message.from === "string" ? message.from : null;
if (!from || !opts.enrolledExecutorDids.includes(from)) return null;

const doc = (message.body ?? {}) as Partial<TrustTask<Record<string, unknown>>>;
const thid =
(typeof message.thid === "string" ? message.thid : undefined) ??
(typeof doc.threadId === "string" ? doc.threadId : undefined);

if (isTrustTaskErrorType(doc.type)) {
const payload = (doc.payload ?? {}) as Partial<TrustTaskErrorPayload>;
return {
accepted: false,
code: typeof payload.code === "string" ? payload.code : "unknown",
...(typeof payload.message === "string" ? { message: payload.message } : {}),
// The framework schema requires `retryable`; treat a missing one as
// "don't retry" rather than inventing optimism about a refusal.
retryable: payload.retryable === true,
...(payload.details !== undefined ? { details: payload.details } : {}),
...(thid ? { thid } : {}),
};
}

if (doc.type === TASK_CONSENT_DECISION_RESPONSE_TYPE) {
const payload = (doc.payload ?? {}) as Record<string, unknown>;
return {
accepted: true,
status: typeof payload.status === "string" ? payload.status : "unknown",
...(typeof payload.approvals === "number" ? { approvals: payload.approvals } : {}),
...(typeof payload.needed === "number" ? { needed: payload.needed } : {}),
...(typeof payload.payloadDigest === "string"
? { payloadDigest: payload.payloadDigest }
: {}),
...(thid ? { thid } : {}),
};
}

return null;
}

/**
* Parse a VTA→requester `task-consent/granted` notice.
Expand Down Expand Up @@ -362,10 +479,23 @@ export async function buildTaskConsentDecisionDocument(
return document;
}

/** A `task-consent/decision` ready to send, and the id to recognise its
* answer by. */
export interface BuiltTaskConsentDecision {
/** The packed, mediator-routed wire message. */
packed: string;
/** The decision document's id. The executor answers on this thread
* (`thid`), so a caller that keeps it can match the reply to the decision
* it sent — and therefore tell the human *which* approval was refused.
* Returned rather than left inside the opaque packed blob because the
* alternative is not correlating at all, which is where this started. */
id: string;
}

/** Build the authcrypted, mediator-routed `task-consent/decision` wire message. */
export async function buildTaskConsentDecision(
args: BuildTaskConsentDecisionArgs,
): Promise<string> {
): Promise<BuiltTaskConsentDecision> {
const document = await buildTaskConsentDecisionDocument(args);

const message = {
Expand All @@ -381,7 +511,8 @@ export async function buildTaskConsentDecision(
{ kid: args.vta.keyAgreementKid, jwk: args.vta.keyAgreementPublicJwk },
]);
const forwardJson = wrapForward(args.vta.did, args.holder.did, args.mediator.did, inner);
return packAuthcryptJson(forwardJson, args.holder, [
const packed = await packAuthcryptJson(forwardJson, args.holder, [
{ kid: args.mediator.keyAgreementKid, jwk: args.mediator.keyAgreementPublicJwk },
]);
return { packed, id: document.id };
}
199 changes: 199 additions & 0 deletions packages/core/tests/inbound.task-consent-outcome.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// The executor's answer to a decision this device sent.
//
// A refusal is the worst inbound event in the ceremony: a human was shown a
// change, agreed to it, and the agreement did not take — so unlike a lost
// prompt, the person believes they have acted. The wallet used to drop that
// reply unread, because nothing recognised it and the inbound handler's final
// branch ignores what it cannot name. An approval the VTA rejected then looked,
// from this side, exactly like one that worked.
//
// These pin the two halves that matter: the answer is *read*, and it is read
// only when it comes from an executor this device is enrolled with.

import { test } from "node:test";
import assert from "node:assert/strict";

import {
parseTaskConsentOutcome,
TASK_CONSENT_DECISION_RESPONSE_TYPE,
} from "../dist/inbound/task-consent.js";
import {
TRUST_TASK_ENVELOPE_TYPE,
TRUST_TASK_ERROR_TYPE,
TRUST_TASK_ERROR_TYPE_0_2,
} from "../dist/vta/protocol.js";

const VTA = "did:webvh:zScid:vta.example:glenn-vta";
const OPTS = { enrolledExecutorDids: [VTA] };
const THID = "urn:uuid:decision-1";

function envelope(body, overrides = {}) {
return {
id: "urn:uuid:reply-1",
type: TRUST_TASK_ENVELOPE_TYPE,
from: VTA,
to: ["did:key:zApprover"],
thid: THID,
body,
...overrides,
};
}

function errorDoc(type, payload) {
return { id: "urn:uuid:err-1", type, threadId: THID, payload };
}

test("a permissionDenied refusal is read, not dropped", () => {
// Precisely the reply that went unread in the field: the transport gate
// refused the approver, and the wallet said nothing.
const outcome = parseTaskConsentOutcome(
envelope(
errorDoc(TRUST_TASK_ERROR_TYPE_0_2, {
code: "permissionDenied",
message: "DID not in ACL: did:key:zApprover",
retryable: false,
}),
),
OPTS,
);
assert.ok(outcome, "the refusal must be recognised");
assert.equal(outcome.accepted, false);
assert.equal(outcome.code, "permissionDenied");
assert.equal(outcome.retryable, false);
assert.match(outcome.message, /not in ACL/);
assert.equal(outcome.thid, THID, "correlates to the decision we sent");
});

test("the 0.1 error type is read too, with its snake_case code left alone", () => {
// `code` is opaque: 0.1 says permission_denied, 0.2 says permissionDenied.
// Normalising here would invite a caller to branch on one casing.
const outcome = parseTaskConsentOutcome(
envelope(
errorDoc(TRUST_TASK_ERROR_TYPE, { code: "permission_denied", retryable: false }),
),
OPTS,
);
assert.equal(outcome.accepted, false);
assert.equal(outcome.code, "permission_denied");
});

test("details ride through — that is where a task-specific reason lives", () => {
const outcome = parseTaskConsentOutcome(
envelope(
errorDoc(TRUST_TASK_ERROR_TYPE_0_2, {
code: "taskFailed",
retryable: false,
details: { payloadDigest: "abc123" },
}),
),
OPTS,
);
assert.deepEqual(outcome.details, { payloadDigest: "abc123" });
});

test("a missing retryable reads as not-retryable, never as optimism", () => {
const outcome = parseTaskConsentOutcome(
envelope(errorDoc(TRUST_TASK_ERROR_TYPE_0_2, { code: "internalError" })),
OPTS,
);
assert.equal(outcome.retryable, false);
});

test("an accepted decision reports the status and the tally", () => {
const outcome = parseTaskConsentOutcome(
envelope({
id: "urn:uuid:ok-1",
type: TASK_CONSENT_DECISION_RESPONSE_TYPE,
threadId: THID,
payload: { status: "granted", payloadDigest: "abc123", approvals: 1 },
}),
OPTS,
);
assert.equal(outcome.accepted, true);
assert.equal(outcome.status, "granted");
assert.equal(outcome.approvals, 1);
assert.equal(outcome.payloadDigest, "abc123");
});

test("a partial approval is accepted, and says how many more are needed", () => {
const outcome = parseTaskConsentOutcome(
envelope({
id: "urn:uuid:ok-2",
type: TASK_CONSENT_DECISION_RESPONSE_TYPE,
threadId: THID,
payload: { status: "pending", payloadDigest: "abc123", approvals: 1, needed: 2 },
}),
OPTS,
);
assert.equal(outcome.accepted, true);
assert.equal(outcome.status, "pending");
assert.equal(outcome.needed, 2);
});

test("a reply from anyone but an enrolled executor is not believed", () => {
// An unauthenticated party must not be able to tell this device its approval
// failed — that is an invitation to approve a second time — nor that one
// succeeded when it did not.
const outcome = parseTaskConsentOutcome(
envelope(errorDoc(TRUST_TASK_ERROR_TYPE_0_2, { code: "permissionDenied", retryable: false }), {
from: "did:key:zSomeoneElse",
}),
OPTS,
);
assert.equal(outcome, null);
});

test("an unattributable reply is dropped", () => {
// No `from` means the transport could not authenticate the sender, and
// nothing downstream re-verifies this document.
const outcome = parseTaskConsentOutcome(
envelope(errorDoc(TRUST_TASK_ERROR_TYPE_0_2, { code: "permissionDenied", retryable: false }), {
from: undefined,
}),
OPTS,
);
assert.equal(outcome, null);
});

test("anything that is not an answer returns null, so other handlers still see it", () => {
// The one case a caller may ignore. A consent *request* must fall through to
// the parser that prompts a human — returning an outcome here would swallow
// the prompt, which is the failure this whole module exists to prevent.
for (const body of [
{ id: "x", type: "https://trusttasks.org/spec/task-consent/request/0.1", payload: {} },
{ id: "x", type: "https://trusttasks.org/spec/vta/webvh/dids/update/1.0", payload: {} },
{},
]) {
assert.equal(parseTaskConsentOutcome(envelope(body), OPTS), null);
}
assert.equal(
parseTaskConsentOutcome(
{ type: "https://didcomm.org/messagepickup/3.0/status", from: VTA },
OPTS,
),
null,
);
});

test("the thid falls back to the document threadId when the envelope omits it", () => {
const outcome = parseTaskConsentOutcome(
envelope(errorDoc(TRUST_TASK_ERROR_TYPE_0_2, { code: "permissionDenied", retryable: false }), {
thid: undefined,
}),
OPTS,
);
assert.equal(outcome.thid, THID);
});

test("an answer with no correlation at all is still reported", () => {
// Losing the thread costs detail, never the report: a refusal the wallet
// cannot match to a specific decision is still a refusal the human needs.
const outcome = parseTaskConsentOutcome(
envelope({ id: "e", type: TRUST_TASK_ERROR_TYPE_0_2, payload: { code: "taskFailed", retryable: false } }, {
thid: undefined,
}),
OPTS,
);
assert.equal(outcome.accepted, false);
assert.equal(outcome.thid, undefined);
});
Loading