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
1 change: 1 addition & 0 deletions packages/core/src/vta/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/vta/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": <TrustTask> }`.
*
* **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
Expand Down
68 changes: 68 additions & 0 deletions packages/core/src/vta/tsp-binding.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<string, unknown>;
}
17 changes: 13 additions & 4 deletions packages/core/src/vta/tsp-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
23 changes: 11 additions & 12 deletions packages/core/src/vta/tsp-inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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";
Expand Down Expand Up @@ -116,15 +120,10 @@ export async function unpackInboundTsp(
);
}

let doc: Record<string, unknown>;
try {
doc = JSON.parse(fromUtf8.decode(opened.payload)) as Record<string, unknown>;
} 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") {
Expand Down
10 changes: 8 additions & 2 deletions packages/core/tests/tsp.channel.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion packages/core/tests/tsp.inbound.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();


Expand Down Expand Up @@ -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,
{
Expand Down
8 changes: 6 additions & 2 deletions packages/core/tests/vta.outbound-signing.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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.
Expand All @@ -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,
{
Expand Down