From 8073471903c69af710b97ad85e1d6e229a5880d7 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Tue, 15 Sep 2026 01:58:54 +0200 Subject: [PATCH] feat(tsp): carry Trust Tasks in the TSP binding envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Over TSP this wallet sealed the **bare document**, and said so: `tsp-channel.ts` carried the comment "TSP plaintext = the Trust-Task envelope JSON (no binding wrapper)", and `tsp-inbound.ts` explained that "over TSP the plaintext *is* the document, with no wrapper". The VTA parsed exactly that. The two agreed with each other and with nothing else. The published binding (`https://trusttasks.org/binding/tsp/0.1`) specifies the payload as `{"type": …/envelope, "document": }`, and `trust-tasks-tsp`'s `unpack_trust_task` refuses anything else with `WrongEnvelopeType`. So a conformant peer would have rejected every frame we sent and we would have rejected all of theirs — and, more to the point, neither side could ever have used the binding library, which is what makes "adding a transport is adding a binding" untrue in practice rather than in principle. ## Why TSP needs a wrapper when the other two bindings do not Each binding has to say "this payload is a Trust Task" somewhere a reader can see before parsing. HTTPS says it with the request path; DIDComm says it with the message `type`. A TSP frame has neither — a sender VID, a recipient VID and opaque bytes — so the binding puts the marker in the JSON. The wrapper is not ceremony; it is the only place TSP has to put it. ## One module, both directions `tsp-binding.ts` wraps outbound and opens inbound, so the binding is a single fact about this package rather than a convention each path remembers. The next transport should be a module beside it, not an edit spread across every sender and receiver. The wrapper goes on **after** signing, and must: the proof is taken over the document, so anything that reshaped it during wrapping would invalidate every signature while looking identical. A payload that is not an envelope, or carries another binding's type, is refused rather than read as a document — and the refusal names what actually arrived, so a peer still sending the old shape can see which half is wrong. Accepting a bare one "just in case" would keep the dialect alive for as long as anything spoke it, and nothing is deployed that needs the kindness. ## Cut over with the VTA A wire change with no deprecation window, which is the rule here: nothing is deployed, so the two sides move together rather than one of them learning to accept both shapes. ## Tests The simulated VTA in `tsp.channel.mjs` now speaks the binding in both directions. That matters more than the assertions around it: a stub that kept accepting a bare document would let this wallet regress to the old dialect with every test still green. `npm test` — 1352 across the workspace, 0 failures. Build clean. Signed-off-by: Glenn Gore --- packages/core/src/vta/index.ts | 1 + packages/core/src/vta/protocol.ts | 19 ++++++ packages/core/src/vta/tsp-binding.ts | 68 ++++++++++++++++++++ packages/core/src/vta/tsp-channel.ts | 17 +++-- packages/core/src/vta/tsp-inbound.ts | 23 ++++--- packages/core/tests/tsp.channel.mjs | 10 ++- packages/core/tests/tsp.inbound.mjs | 6 +- packages/core/tests/vta.outbound-signing.mjs | 8 ++- 8 files changed, 131 insertions(+), 21 deletions(-) create mode 100644 packages/core/src/vta/tsp-binding.ts diff --git a/packages/core/src/vta/index.ts b/packages/core/src/vta/index.ts index b269835..28a6201 100644 --- a/packages/core/src/vta/index.ts +++ b/packages/core/src/vta/index.ts @@ -18,6 +18,7 @@ export * from "./auth.js"; export * from "./auth-tasks.js"; export * from "./transport.js"; export * from "./trust-task.js"; +export * from "./tsp-binding.js"; export * from "./tsp-channel.js"; export * from "./tsp-inbound.js"; export * from "./tsp-mediator-transport.js"; diff --git a/packages/core/src/vta/protocol.ts b/packages/core/src/vta/protocol.ts index ebf4a65..6b185cc 100644 --- a/packages/core/src/vta/protocol.ts +++ b/packages/core/src/vta/protocol.ts @@ -34,6 +34,25 @@ import { export const TRUST_TASK_ENVELOPE_TYPE = "https://trusttasks.org/binding/didcomm/0.1/envelope"; +/** + * The TSP binding's payload wrapper `type` (binding 0.1). + * + * A TSP frame carries a sender VID, a recipient VID and opaque bytes — it has + * no message `type` of its own the way DIDComm does, and no request path the + * way HTTPS does. So the binding puts the marker in the JSON payload: + * `{ "type": TSP_BINDING_ENVELOPE_TYPE, "document": }`. + * + * **This wrapper used to be omitted at both ends of this workspace**, which is + * why it is worth a note rather than a line. The wallet sealed the bare + * document and the VTA parsed one, so the two agreed with each other and with + * nothing else: a conformant peer built on `trust-tasks-tsp` would have refused + * every frame with `WrongEnvelopeType`, and neither side could use the binding + * library at all. Adopted together with the VTA — no deprecation window, + * because nothing is deployed. + */ +export const TSP_BINDING_ENVELOPE_TYPE = + "https://trusttasks.org/binding/tsp/0.1/envelope"; + /** Framework error-document `type` — a `TrustTask` whose payload is a * {@link TrustTaskErrorPayload}. The 0.1 form; later framework versions emit * {@link TRUST_TASK_ERROR_TYPE_0_2} or {@link TRUST_TASK_ERROR_TYPE_0_3}. Use diff --git a/packages/core/src/vta/tsp-binding.ts b/packages/core/src/vta/tsp-binding.ts new file mode 100644 index 0000000..2733aef --- /dev/null +++ b/packages/core/src/vta/tsp-binding.ts @@ -0,0 +1,68 @@ +// The TSP transport binding: how a Trust Task is carried in a TSP payload. +// +// One module, both directions — outbound frames are wrapped here and inbound +// ones opened here, so the binding is a single fact about this package rather +// than a convention each path remembers. The next transport should be a module +// beside this one, not an edit spread across every sender and receiver. +// +// ## Why TSP needs a wrapper when the other two bindings do not +// +// Each binding has to say "this payload is a Trust Task" somewhere a reader can +// see before parsing. HTTPS says it with the request path (`POST …/trust-tasks`) +// and DIDComm with the message `type`. A TSP frame has neither — a sender VID, a +// recipient VID and opaque bytes — so the binding puts it in the JSON. +// +// ## What this replaces +// +// Both ends of this workspace sealed the bare document and said so in comments: +// this package's `tsp-channel.ts` carried "TSP plaintext = the Trust-Task +// envelope JSON (no binding wrapper)", and the VTA's inbound module called its +// payload "identical to the REST body". They agreed with each other and with +// nothing else — a conformant peer built on `trust-tasks-tsp` would have refused +// every frame with `WrongEnvelopeType`, and neither side could have used the +// binding library at all. Cut over with the VTA in one change; nothing is +// deployed, so there is no window to keep the old shape alive for. + +import { VtaClientError } from "./errors.js"; +import { TSP_BINDING_ENVELOPE_TYPE } from "./protocol.js"; + +/** Wrap a Trust-Task document in the binding envelope. */ +export function wrapTspEnvelope(document: unknown): string { + return JSON.stringify({ type: TSP_BINDING_ENVELOPE_TYPE, document }); +} + +/** + * Open a TSP binding envelope and return the Trust-Task document. + * + * A payload that is not an envelope, or carries another binding's type, is + * refused rather than read as a document. Accepting a bare one "just in case" + * would keep the old dialect alive on the wire for as long as anything spoke + * it, and the refusal is what tells a misconfigured peer which half is wrong. + */ +export function openTspEnvelope(plaintext: string): Record { + let envelope: unknown; + try { + envelope = JSON.parse(plaintext); + } catch (err) { + throw new VtaClientError( + "e.client.parse", + `tsp: payload is not JSON: ${(err as Error).message}`, + ); + } + if (typeof envelope !== "object" || envelope === null) { + throw new VtaClientError("e.client.parse", "tsp: payload is not an object"); + } + const { type, document } = envelope as { type?: unknown; document?: unknown }; + if (type !== TSP_BINDING_ENVELOPE_TYPE) { + // Names what arrived, so a peer sending another binding's wrapper — or the + // bare document this workspace used to send — can see which it did. + throw new VtaClientError( + "e.client.parse", + `tsp: payload is not a ${TSP_BINDING_ENVELOPE_TYPE} envelope (got ${JSON.stringify(type)})`, + ); + } + if (typeof document !== "object" || document === null) { + throw new VtaClientError("e.client.parse", "tsp: envelope carries no `document`"); + } + return document as Record; +} diff --git a/packages/core/src/vta/tsp-channel.ts b/packages/core/src/vta/tsp-channel.ts index 8a294e8..0b7c5c3 100644 --- a/packages/core/src/vta/tsp-channel.ts +++ b/packages/core/src/vta/tsp-channel.ts @@ -18,6 +18,8 @@ // simulator in tests). import { pack, unpack } from "@openvtc/vti-tsp-js"; + +import { openTspEnvelope, wrapTspEnvelope } from "./tsp-binding.js"; import { ed25519, x25519 } from "@noble/curves/ed25519.js"; import type { TspFrameClaim } from "../didcomm/index.js"; @@ -157,8 +159,11 @@ export class TspChannel implements TrustTaskChannel { // Both `send` and `notify` seal through here, so this is the one place the // proof has to be attached — before the JSON the seal is taken over. await signOutboundTask(envelope, this.signer); - // TSP plaintext = the Trust-Task envelope JSON (no binding wrapper). - const plaintext = utf8.encode(JSON.stringify(envelope)); + // TSP plaintext = the binding envelope, with the signed document inside it. + // The wrapper goes on *after* signing, and must: the proof is taken over the + // document, so anything that reshaped it here would invalidate every + // signature while looking identical on screen. + const plaintext = utf8.encode(wrapTspEnvelope(envelope)); const packed = await pack(plaintext, this.holder.vid, this.vta.vid, { senderSigningKey: this.holder.signingPrivateKey, senderEncryptionKey: this.holder.encryptionPrivateKey, @@ -225,9 +230,13 @@ export class TspChannel implements TrustTaskChannel { } let doc: { type?: string; id?: unknown; payload?: unknown; threadId?: unknown }; try { - doc = JSON.parse(fromUtf8.decode(reply.payload)) as typeof doc; + // The reply comes back in the same binding envelope it was sent in. A + // reply that dropped the wrapper would make the binding asymmetric — + // conformant one way and not the other — which is harder to notice than + // being wrong in both directions. + doc = openTspEnvelope(fromUtf8.decode(reply.payload)) as typeof doc; } catch (err) { - lastDecline = `payload not JSON: ${(err as Error).message}`; + lastDecline = (err as Error).message; return false; } // `threadId` on a response is the request's `threadId` or, as here, its diff --git a/packages/core/src/vta/tsp-inbound.ts b/packages/core/src/vta/tsp-inbound.ts index 2310a83..6f18c5c 100644 --- a/packages/core/src/vta/tsp-inbound.ts +++ b/packages/core/src/vta/tsp-inbound.ts @@ -3,9 +3,11 @@ // // The VTA pushes `task-consent` and step-up requests to a wallet. Over DIDComm // those arrive as a binding envelope (`TRUST_TASK_ENVELOPE_TYPE`) whose `body` -// is the Trust-Task document. Over TSP the plaintext *is* the document, with no -// wrapper — so the two paths differ only in carriage, and this module makes -// that the only difference the inbound pipeline sees. +// is the Trust-Task document; over TSP as the TSP binding's own envelope +// (`TSP_BINDING_ENVELOPE_TYPE`), whose `document` is the same thing. Each +// binding says "this is a Trust Task" in the one place its transport gives it — +// so the two paths differ only in carriage, and this module makes that the only +// difference the inbound pipeline sees. // // **The pipeline is already document-centric**, which is why the adaptation is // honest rather than a fudge: `parseTaskConsentRequest` verifies the @@ -27,6 +29,8 @@ import { decodeEnvelope, unpack } from "@openvtc/vti-tsp-js"; +import { openTspEnvelope } from "./tsp-binding.js"; + import { VtaClientError } from "./errors.js"; import { TRUST_TASK_ENVELOPE_TYPE } from "./protocol.js"; import type { TspHolderIdentity, TspRemoteEndpoint } from "./tsp-channel.js"; @@ -116,15 +120,10 @@ export async function unpackInboundTsp( ); } - let doc: Record; - try { - doc = JSON.parse(fromUtf8.decode(opened.payload)) as Record; - } catch (err) { - throw new VtaClientError( - "e.client.parse", - `tsp inbound: payload is not JSON: ${(err as Error).message}`, - ); - } + // The binding envelope comes off here, and nowhere else in this path: + // everything below works on the Trust-Task document, exactly as the DIDComm + // inbound does once its own envelope is unwrapped. + const doc = openTspEnvelope(fromUtf8.decode(opened.payload)); const id = typeof doc.id === "string" ? doc.id : undefined; if (!id || typeof doc.type !== "string") { diff --git a/packages/core/tests/tsp.channel.mjs b/packages/core/tests/tsp.channel.mjs index 247e7ca..f03d53d 100644 --- a/packages/core/tests/tsp.channel.mjs +++ b/packages/core/tests/tsp.channel.mjs @@ -7,6 +7,8 @@ import { ed25519, x25519 } from "@noble/curves/ed25519.js"; import { signTrustTask } from "../dist/trust-tasks/sign.js"; import { generateSigningIdentity } from "../dist/siop/self-issued.js"; +import { openTspEnvelope, wrapTspEnvelope } from "../dist/vta/tsp-binding.js"; + const utf8 = new TextEncoder(); const fromUtf8 = new TextDecoder(); @@ -48,7 +50,11 @@ function simulatedVtaTransport(vta, holder, dispatch, replySenderVid) { }); assert.equal(req.sender, holder.vid); assert.equal(req.receiver, vta.vid); - const reqDoc = JSON.parse(fromUtf8.decode(req.payload)); + // The simulated VTA speaks the binding, like the real one: it opens the + // envelope to read the request and seals its reply back in one. A stub + // that accepted a bare document would let the wallet regress to the old + // dialect with every test still green. + const reqDoc = openTspEnvelope(fromUtf8.decode(req.payload)); const replyDoc = dispatch(reqDoc); // The real VTA threads its response to the request: `respond_with` sets // `thread_id = self.thread_id.or(self.id)`. The channel correlates on @@ -69,7 +75,7 @@ function simulatedVtaTransport(vta, holder, dispatch, replySenderVid) { // Seal the reply under `replySenderVid` (defaults to the VTA's real VID), // still using the VTA's keys — so the channel's own sender-VID check is // what's exercised, not a crypto failure. - const sealed = await pack(utf8.encode(JSON.stringify(replyDoc)), replySenderVid ?? vta.vid, holder.vid, { + const sealed = await pack(utf8.encode(wrapTspEnvelope(replyDoc)), replySenderVid ?? vta.vid, holder.vid, { senderSigningKey: vta.signSk, senderEncryptionKey: vta.encSk, receiverEncryptionKey: holder.encPk, diff --git a/packages/core/tests/tsp.inbound.mjs b/packages/core/tests/tsp.inbound.mjs index 424d3e2..1ba762a 100644 --- a/packages/core/tests/tsp.inbound.mjs +++ b/packages/core/tests/tsp.inbound.mjs @@ -14,6 +14,8 @@ import { unpackInboundTsp, TRUST_TASK_ENVELOPE_TYPE } from "../dist/index.js"; import { pack } from "@openvtc/vti-tsp-js"; import { ed25519, x25519 } from "@noble/curves/ed25519.js"; +import { wrapTspEnvelope } from "../dist/vta/tsp-binding.js"; + const utf8 = new TextEncoder(); @@ -50,9 +52,11 @@ function resolverFor(endpoint) { }); } +/// A document is sealed the way the binding requires; a raw string is sealed +/// verbatim, which is how the malformed-carriage cases are written. async function sealed(payload, { from = executor, senderVid = from.vid } = {}) { const out = await pack( - utf8.encode(typeof payload === "string" ? payload : JSON.stringify(payload)), + utf8.encode(typeof payload === "string" ? payload : wrapTspEnvelope(payload)), senderVid, holder.vid, { diff --git a/packages/core/tests/vta.outbound-signing.mjs b/packages/core/tests/vta.outbound-signing.mjs index c631d9a..cb14898 100644 --- a/packages/core/tests/vta.outbound-signing.mjs +++ b/packages/core/tests/vta.outbound-signing.mjs @@ -39,6 +39,8 @@ import { // `vault/delete/0.1` is one of the 93 — a mutation, and proof REQUIRED. const VAULT_DELETE = "https://trusttasks.org/spec/vault/delete/0.1"; +import { openTspEnvelope, wrapTspEnvelope } from "../dist/vta/tsp-binding.js"; + const utf8 = new TextEncoder(); const fromUtf8 = new TextDecoder(); @@ -166,7 +168,9 @@ test("TSP: the sealed document carries a proof, distinct from the outer signatur senderEncryptionKey: x25519.getPublicKey(holderEncSk), senderSigningKey: ed25519.getPublicKey(holderSignSk), }); - received = JSON.parse(fromUtf8.decode(opened.payload)); + // Opened through the binding: what the wallet seals is the envelope, and + // the document under test is inside it. + received = openTspEnvelope(fromUtf8.decode(opened.payload)); // Signed, because a real VTA signs its responses and the channel refuses // an unsigned one. Built fully first: a proof covers the document it was // made over, so anything added after it would invalidate it. @@ -179,7 +183,7 @@ test("TSP: the sealed document carries a proof, distinct from the outer signatur }; await signTrustTask({ envelope: replyDoc, signing: vtaSigning }); const reply = await pack( - utf8.encode(JSON.stringify(replyDoc)), + utf8.encode(wrapTspEnvelope(replyDoc)), vtaVid, holderVid, {