diff --git a/package-lock.json b/package-lock.json index 315ea02..880320f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2307,9 +2307,9 @@ "license": "Apache-2.0" }, "node_modules/@openvtc/vti-didcomm-js": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@openvtc/vti-didcomm-js/-/vti-didcomm-js-0.6.2.tgz", - "integrity": "sha512-91UQ754p8tF6RqJccLCRzqp36EGwF9bTCXsJvsaryzu1/ctYlDx3jrnUFsPulqp0LPM1Jvs7rhKBnLrgJ7z5ug==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@openvtc/vti-didcomm-js/-/vti-didcomm-js-0.7.0.tgz", + "integrity": "sha512-SVJtr0XpapI3289eHCqcczaJ3xT3nJYh/q9y2iOJpsrBT+bW3OywduovUWR/v39F3iIxxlnr5W5jrqDDf6RjAA==", "license": "Apache-2.0", "dependencies": { "@noble/curves": "^2.2.0", @@ -7807,7 +7807,7 @@ "@cfworker/json-schema": "^4.1.1", "@noble/curves": "^2.4.0", "@openvtc/trust-tasks": "^0.16.3", - "@openvtc/vti-didcomm-js": "^0.6.2", + "@openvtc/vti-didcomm-js": "^0.7.0", "@openvtc/vti-tsp-js": "^0.2.0", "@scure/base": "^2.2.0", "cbor-x": "^1.6.6" diff --git a/packages/core/package.json b/packages/core/package.json index 049f643..a087975 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -121,7 +121,7 @@ "@cfworker/json-schema": "^4.1.1", "@noble/curves": "^2.4.0", "@openvtc/trust-tasks": "^0.16.3", - "@openvtc/vti-didcomm-js": "^0.6.2", + "@openvtc/vti-didcomm-js": "^0.7.0", "@openvtc/vti-tsp-js": "^0.2.0", "@scure/base": "^2.2.0", "cbor-x": "^1.6.6" diff --git a/packages/core/src/didcomm/index.ts b/packages/core/src/didcomm/index.ts index 2faf45f..44c66aa 100644 --- a/packages/core/src/didcomm/index.ts +++ b/packages/core/src/didcomm/index.ts @@ -537,11 +537,22 @@ export interface MediatorConnection { * and DIDComm share one socket per holder DID (no second socket, so no * one-socket-per-DID conflict; the reply arrives back on this socket). */ sendBinary(bytes: Uint8Array): void; - /** Await the next inbound TSP frame. FIFO — TSP carries no thread id, and a - * VtaSession drives one request at a time. Call this to register the waiter, - * then `sendBinary` (both synchronous, so no frame can arrive between them). - * Rejects on timeout. Frames arriving with no waiter are discarded. */ - awaitTspFrame(timeoutMs: number): Promise; + /** Await the inbound TSP frame that `claims` recognises as this request's + * reply. Call this to register the waiter, then `sendBinary` (both + * synchronous, so no frame can arrive between them). Rejects on timeout. + * + * **`claims` is not optional, and FIFO is not a substitute for it.** This + * used to hand the next frame to the next waiter, which was sound only + * while replies were the only thing arriving on the socket. They are not: + * the VTA pushes `task-consent` and step-up requests over the same + * connection, and under FIFO a push landing mid-request would be handed to + * that request's waiter and parsed as its reply — while the push itself + * vanished. The frame is opaque here (this layer holds no TSP keys), so + * only the caller can tell one from the other; it unpacks and matches on + * the Trust-Task `threadId`. + * + * A frame no waiter claims is unsolicited, and goes to `onInboundTsp`. */ + awaitTspFrame(timeoutMs: number, claims: TspFrameClaim): Promise; close(): void; /** True while the underlying WebSocket is open (live delivery active). A * warm-session holder checks this before reusing a cached connection. */ @@ -565,12 +576,40 @@ export interface MediatorConnection { onInbound( handler: (message: Record, thid: string) => void | Promise, ): void; + /** Register a handler for inbound **TSP** frames no waiter claimed — the + * executor-initiated requests (`task-consent`, step-up) that arrive over + * TSP rather than DIDComm. Replaces any previously-registered handler. + * + * Receives the raw qb2 bytes, still sealed: this layer holds no TSP keys. + * `unpackInboundTsp` (`vta/tsp-inbound.ts`) is what turns them into a + * verified message, resolving the sender's keys from the VID the frame + * names in cleartext and then *proving* it on unpack. + * + * The same R1.6 contract as {@link onInbound}: awaited before the frame is + * acked (vti-didcomm-js >=0.7.0), so resolve once the message is durably + * stored — not when the work is finished. A throw withholds the ack and the + * mediator redelivers, so handlers must de-duplicate. */ + onInboundTsp(handler: (bytes: Uint8Array) => void | Promise): void; /** Resolved VTA key-agreement endpoint (inner authcrypt target). */ vta: ResolvedKeyAgreement; /** Resolved mediator key-agreement endpoint (forward-envelope target). */ mediator: ResolvedKeyAgreement; } +/** + * Decides whether an inbound TSP frame is the reply to one outstanding + * request. + * + * The connection layer holds no TSP keys — a frame is opaque bytes to it — so + * recognising a reply is necessarily the caller's job. `TspChannel` unpacks + * with the VTA keys it addressed and matches the Trust-Task `threadId`, which + * threads to the request `id` exactly as DIDComm's `thid ?? id` does. + * + * Return `false` (or throw) for anything not yours: an unclaimed frame is + * offered to the next waiter, and finally to the unsolicited-inbound handler. + */ +export type TspFrameClaim = (bytes: Uint8Array) => boolean | Promise; + export interface ConnectMediatorSessionOptions { /** Holder identity (its X25519 key authenticates to the mediator). */ holder: Identity; @@ -633,8 +672,10 @@ export async function connectMediatorSession( const tspWaiters: Array<{ resolve: (b: Uint8Array) => void; reject: (e: Error) => void; + claims: TspFrameClaim; timer: ReturnType; }> = []; + let inboundTspHandler: ((bytes: Uint8Array) => void | Promise) | undefined; const rejectTspWaiters = (err: Error) => { while (tspWaiters.length) { const w = tspWaiters.shift()!; @@ -657,12 +698,36 @@ export async function connectMediatorSession( const r = await vtiResolveKeyAgreement(did); return { publicJwk: x25519PublicJwk(r.x25519Pub) }; }, - onTspFrame: (bytes: Uint8Array) => { - const w = tspWaiters.shift(); - if (w) { - clearTimeout(w.timer); - w.resolve(bytes); - } // else: straggler with no outstanding request — discard. + // Awaited by the transport before it acks (vti-didcomm-js >=0.7.0), so + // everything this does happens while the mediator still holds its copy — + // the same R1.6 ordering `onInbound` gets. A throw withholds the ack and + // the frame is redelivered. + onTspFrame: async (bytes: Uint8Array) => { + // Offer the frame to each outstanding request in turn; the first that + // recognises it as its own reply takes it. Ordered, not FIFO: a waiter + // only claims a frame it can unpack AND whose `threadId` threads to its + // request, so a push arriving mid-request falls through to the inbound + // handler instead of being consumed as somebody's answer. + for (let i = 0; i < tspWaiters.length; i++) { + const w = tspWaiters[i]!; + let claimed = false; + try { + claimed = await w.claims(bytes); + } catch { + // A claim predicate that throws has not claimed anything. It must + // not take down the frame for every other consumer. + claimed = false; + } + if (claimed) { + tspWaiters.splice(i, 1); + clearTimeout(w.timer); + w.resolve(bytes); + return; + } + } + // Unclaimed: an executor-initiated request (task-consent, step-up). + // Awaited so a handler that persists finishes before the ack. + if (inboundTspHandler) await inboundTspHandler(bytes); }, ...(opts.onClose ? { onClose: opts.onClose } : {}), ...(opts.webSocketImpl ? { WebSocketImpl: opts.webSocketImpl } : {}), @@ -675,14 +740,14 @@ export async function connectMediatorSession( waitFor: (thid: string, timeoutMs: number) => session.waitFor(thid, timeoutMs) as Promise>, sendBinary: (bytes: Uint8Array) => session.sendBinary(bytes), - awaitTspFrame: (timeoutMs: number) => + awaitTspFrame: (timeoutMs: number, claims: TspFrameClaim) => new Promise((resolve, reject) => { const timer = setTimeout(() => { const i = tspWaiters.indexOf(waiter); if (i >= 0) tspWaiters.splice(i, 1); reject(new Error("timed out awaiting reply frame")); }, timeoutMs); - const waiter = { resolve, reject, timer }; + const waiter = { resolve, reject, claims, timer }; tspWaiters.push(waiter); }), close: () => { @@ -697,6 +762,9 @@ export async function connectMediatorSession( onInbound: (handler) => { (session as unknown as { onMessage: typeof handler }).onMessage = handler; }, + onInboundTsp: (handler) => { + inboundTspHandler = handler; + }, vta, mediator: { did: auth.mediator.did, diff --git a/packages/core/src/vta/index.ts b/packages/core/src/vta/index.ts index cd7ebdf..b269835 100644 --- a/packages/core/src/vta/index.ts +++ b/packages/core/src/vta/index.ts @@ -19,6 +19,7 @@ export * from "./auth-tasks.js"; export * from "./transport.js"; export * from "./trust-task.js"; export * from "./tsp-channel.js"; +export * from "./tsp-inbound.js"; export * from "./tsp-mediator-transport.js"; export * from "./tsp-vid.js"; export * from "./types.js"; diff --git a/packages/core/src/vta/tsp-channel.ts b/packages/core/src/vta/tsp-channel.ts index 5b7a0d7..8e3c69b 100644 --- a/packages/core/src/vta/tsp-channel.ts +++ b/packages/core/src/vta/tsp-channel.ts @@ -20,6 +20,7 @@ import { pack, unpack } from "@openvtc/vti-tsp-js"; import { ed25519, x25519 } from "@noble/curves/ed25519.js"; +import type { TspFrameClaim } from "../didcomm/index.js"; import type { NotifyOpts, SendOpts, TrustTaskChannel } from "./channel.js"; import { VtaClientError } from "./errors.js"; import type { TrustTask } from "./protocol.js"; @@ -81,8 +82,18 @@ export interface TspRemoteEndpoint { * directly testable with a simulator. */ export interface TspTransport { - /** Send a packed TSP message and await the packed reply. */ - sendAndAwaitReply(packed: Uint8Array, options?: { timeoutMs?: number }): Promise; + /** Send a packed TSP message and await the packed reply. + * + * `claims` decides which inbound frame *is* the reply. The transport shares + * one socket with the wallet's inbox, so frames it did not ask for — a + * `task-consent` push, a step-up request — arrive on it too; without a + * predicate the next frame would be handed to the next waiter and a push + * parsed as somebody's answer. Only this layer can tell them apart, because + * only it holds the keys. */ + sendAndAwaitReply( + packed: Uint8Array, + options?: { timeoutMs?: number; claims?: TspFrameClaim }, + ): Promise; /** * Send a packed TSP message without awaiting a reply, for tasks that define * no response document. @@ -177,36 +188,51 @@ export class TspChannel implements TrustTaskChannel { async send(envelope: TrustTask, opts: SendOpts = {}): Promise { const packed = { bytes: await this.packForVta(envelope) }; - const replyBytes = await this.transport.sendAndAwaitReply(packed.bytes, { + // Set by `claims` when it recognises a frame as this request's reply, so + // the frame is unpacked once rather than again on the way out. + let claimedDoc: { type?: string; payload?: unknown } | undefined; + + // Only a frame that (a) unpacks under the VTA keys we addressed, (b) is + // *proven* to come from that VTA, and (c) threads to this request is our + // reply. Anything else — most importantly an executor-initiated push + // landing mid-request — is left for the next waiter or the inbox. + const claims: TspFrameClaim = async (bytes) => { + let reply; + try { + reply = await unpack(bytes, { + receiverDecryptionKey: this.holder.encryptionPrivateKey, + senderEncryptionKey: this.vta.encryptionPublicKey, + senderSigningKey: this.vta.signingPublicKey, + }); + } catch { + // Not unpackable under these keys, so not ours. Not an error: another + // waiter's peer, or an inbound from someone else entirely. + return false; + } + if (reply.sender !== this.vta.vid) return false; + let doc: { type?: string; payload?: unknown; threadId?: unknown }; + try { + doc = JSON.parse(fromUtf8.decode(reply.payload)) as typeof doc; + } catch { + return false; + } + // `threadId` on a response is the request's `threadId` or, as here, its + // `id` — the same `thid ?? id` rule DIDComm correlates on. + if (doc.threadId !== envelope.id) return false; + claimedDoc = doc; + return true; + }; + + await this.transport.sendAndAwaitReply(packed.bytes, { timeoutMs: opts.timeoutMs ?? this.timeoutMs, + claims, }); - let reply; - try { - reply = await unpack(replyBytes, { - receiverDecryptionKey: this.holder.encryptionPrivateKey, - senderEncryptionKey: this.vta.encryptionPublicKey, - senderSigningKey: this.vta.signingPublicKey, - }); - } catch (err) { - throw new VtaClientError("e.client.parse", `tsp reply unpack failed: ${(err as Error).message}`); - } - - // The reply is sealed + signed by the VTA; unpack already verified the - // signature and sender-auth against the VTA's keys. Defence-in-depth: the - // proven sender VID must be the VTA we addressed. - if (reply.sender !== this.vta.vid) { - throw new VtaClientError( - "e.p.msg.unauthorized", - `tsp reply from ${reply.sender} != VTA ${this.vta.vid}`, - ); - } - - let doc: { type?: string; payload?: unknown }; - try { - doc = JSON.parse(fromUtf8.decode(reply.payload)) as { type?: string; payload?: unknown }; - } catch (err) { - throw new VtaClientError("e.client.parse", `tsp reply body not JSON: ${(err as Error).message}`); + // `claims` returned true, so it parsed the document; the transport cannot + // resolve without one having claimed. Defensive only. + const doc = claimedDoc; + if (!doc) { + throw new VtaClientError("e.client.parse", "tsp: reply resolved with no claimed document"); } return parseTrustTaskReply(doc, { diff --git a/packages/core/src/vta/tsp-inbound.ts b/packages/core/src/vta/tsp-inbound.ts new file mode 100644 index 0000000..2310a83 --- /dev/null +++ b/packages/core/src/vta/tsp-inbound.ts @@ -0,0 +1,153 @@ +// Executor-initiated TSP messages: verify one, and shape it like the DIDComm +// inbound the wallet already knows how to handle. +// +// 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. +// +// **The pipeline is already document-centric**, which is why the adaptation is +// honest rather than a fudge: `parseTaskConsentRequest` verifies the +// Data-Integrity proof on the document, and dedup is claimed on the document +// (SPEC §7.2 item 11 — "transport message identifiers MUST NOT substitute for +// the document `id`"). Nothing downstream trusts the envelope for anything +// security-bearing. What it does read is `type`, as the discriminator for "this +// carries a Trust-Task document", and `from`, for the sender — and both of +// those this module supplies truthfully. +// +// **The sender is a candidate until unpack proves it.** A TSP frame names its +// sender and receiver in cleartext CESR, before any crypto, which is how the +// mediator routes without keys. We read that VID only to look up which keys to +// try; `unpack` then verifies the Ed25519 signature and the HPKE-Auth +// sender-binding against them, so a frame claiming to be from an executor it +// cannot authenticate as fails there rather than reaching a human. Reading the +// cleartext VID and *believing* it are different acts, and only the first +// happens here. + +import { decodeEnvelope, unpack } from "@openvtc/vti-tsp-js"; + +import { VtaClientError } from "./errors.js"; +import { TRUST_TASK_ENVELOPE_TYPE } from "./protocol.js"; +import type { TspHolderIdentity, TspRemoteEndpoint } from "./tsp-channel.js"; + +const fromUtf8 = new TextDecoder(); + +export interface UnpackInboundTspOptions { + /** The wallet identity the frame is sealed to. */ + holder: TspHolderIdentity; + /** Resolve a sender VID to its TSP keys. Called with the VID the frame names + * in cleartext, whose authenticity `unpack` then decides. */ + resolveSender: (vid: string) => Promise; +} + +/** + * The DIDComm-shaped message an inbound TSP frame becomes. + * + * `id` is the **Trust-Task document's** id, not a transport id — TSP has no + * envelope id to borrow, and the document id is the one SPEC §7.2 item 11 says + * to key on anyway. That makes the TSP path structurally closer to the spec + * than the DIDComm one, where `message.id` is the sender's transport id and + * dedup has to reach past it into `body`. + */ +export interface InboundTspMessage extends Record { + id: string; + type: string; + from: string; + to: string[]; + body: Record; +} + +/** + * Verify a sealed inbound TSP frame and shape it for the inbound pipeline. + * + * Throws a {@link VtaClientError} when the frame is unreadable, unverifiable, + * or carries something that is not a Trust-Task document. Every one of those is + * a message that must not reach a prompt. + */ +export async function unpackInboundTsp( + bytes: Uint8Array, + opts: UnpackInboundTspOptions, +): Promise { + let claimedSender: string; + try { + claimedSender = decodeEnvelope(bytes).envelope.sender; + } catch (err) { + throw new VtaClientError( + "e.client.parse", + `tsp inbound: unreadable envelope: ${(err as Error).message}`, + ); + } + + let sender: TspRemoteEndpoint; + try { + sender = await opts.resolveSender(claimedSender); + } catch (err) { + throw new VtaClientError( + "e.client.parse", + `tsp inbound: cannot resolve sender ${claimedSender}: ${(err as Error).message}`, + ); + } + + let opened; + try { + opened = await unpack(bytes, { + receiverDecryptionKey: opts.holder.encryptionPrivateKey, + senderEncryptionKey: sender.encryptionPublicKey, + senderSigningKey: sender.signingPublicKey, + }); + } catch (err) { + // Signature or sender-auth failure: the cleartext VID was a claim this + // frame could not back up. + throw new VtaClientError( + "e.p.msg.unauthorized", + `tsp inbound: unpack failed for claimed sender ${claimedSender}: ${(err as Error).message}`, + ); + } + + // `unpack` verified against the keys we resolved for `claimedSender`, so a + // disagreement here would mean the library reported a sender it did not + // check. Cheap to assert, and the assertion is the whole basis for putting + // `from` on the message below. + if (opened.sender !== claimedSender) { + throw new VtaClientError( + "e.p.msg.unauthorized", + `tsp inbound: proven sender ${opened.sender} != envelope sender ${claimedSender}`, + ); + } + + 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}`, + ); + } + + const id = typeof doc.id === "string" ? doc.id : undefined; + if (!id || typeof doc.type !== "string") { + // Not a Trust-Task document. Refused rather than forwarded: the pipeline + // would key dedup on a missing id, and an inbound with no stable identity + // is one that cannot be de-duplicated or recovered. + throw new VtaClientError( + "e.client.parse", + "tsp inbound: payload is not a Trust-Task document (no `id`/`type`)", + ); + } + + return { + id, + // The discriminator the inbound parsers read for "carries a Trust-Task + // document". It is spelled as the DIDComm binding's envelope type because + // that is the constant those parsers compare against; it says what the + // message contains, not which wire carried it. The transport is recorded + // separately below rather than encoded in this field. + type: TRUST_TASK_ENVELOPE_TYPE, + from: opened.sender, + to: [opts.holder.vid], + body: doc, + transport: "tsp", + }; +} diff --git a/packages/core/src/vta/tsp-mediator-transport.ts b/packages/core/src/vta/tsp-mediator-transport.ts index 232811d..65c9fed 100644 --- a/packages/core/src/vta/tsp-mediator-transport.ts +++ b/packages/core/src/vta/tsp-mediator-transport.ts @@ -20,6 +20,7 @@ // send binary, await the reply frame, with the transport-phase error codes // `TspChannel`/`VtaSession` expect. +import type { TspFrameClaim } from "../didcomm/index.js"; import { VtaClientError } from "./errors.js"; import type { TspTransport } from "./tsp-channel.js"; @@ -30,8 +31,9 @@ const DEFAULT_TIMEOUT_MS = 30_000; export interface TspCapableConnection { /** Send a raw TSP message (qb2 bytes) as a binary frame over the socket. */ sendBinary(bytes: Uint8Array): void; - /** Await the next inbound TSP frame (FIFO). Rejects on timeout. */ - awaitTspFrame(timeoutMs: number): Promise; + /** Await the inbound TSP frame `claims` recognises as this request's reply. + * Rejects on timeout. A frame nothing claims is unsolicited inbound. */ + awaitTspFrame(timeoutMs: number, claims: TspFrameClaim): Promise; } export interface MediatorSessionTspTransportOptions { @@ -70,13 +72,27 @@ export class MediatorSessionTspTransport implements TspTransport { async sendAndAwaitReply( packed: Uint8Array, - options: { timeoutMs?: number } = {}, + options: { timeoutMs?: number; claims?: TspFrameClaim } = {}, ): Promise { const timeoutMs = options.timeoutMs ?? this.timeoutMs; + // Without a predicate this waiter would claim the first frame to arrive — + // including an executor-initiated push meant for the inbox. Refusing is the + // safe failure: `e.client.unsupported` is pre-send, so a VtaSession simply + // uses the next channel. A caller that reaches this has a bug, not a + // network problem. + const claims = options.claims; + if (!claims) { + throw new VtaClientError( + "e.client.unsupported", + "tsp: sendAndAwaitReply needs a `claims` predicate to tell its reply " + + "from an unsolicited inbound frame on the shared socket", + ); + } + // Register the reply waiter BEFORE sending (both synchronous — no frame can // arrive between them), per the MediatorConnection contract. - const replyPromise = this.conn.awaitTspFrame(timeoutMs); + const replyPromise = this.conn.awaitTspFrame(timeoutMs, claims); try { this.conn.sendBinary(packed); diff --git a/packages/core/tests/tsp.channel.mjs b/packages/core/tests/tsp.channel.mjs index cc7d135..631a734 100644 --- a/packages/core/tests/tsp.channel.mjs +++ b/packages/core/tests/tsp.channel.mjs @@ -28,7 +28,9 @@ function tspIdentity(vid) { */ function simulatedVtaTransport(vta, holder, dispatch, replySenderVid) { return { - async sendAndAwaitReply(packed) { + /** What the channel's predicate said about the last sealed reply. */ + lastClaim: undefined, + async sendAndAwaitReply(packed, options = {}) { const req = await unpack(packed, { receiverDecryptionKey: vta.encSk, senderEncryptionKey: holder.encPk, @@ -38,6 +40,12 @@ function simulatedVtaTransport(vta, holder, dispatch, replySenderVid) { assert.equal(req.receiver, vta.vid); const reqDoc = JSON.parse(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 + // exactly that, so a double that omitted it would let a broken predicate + // pass. A dispatch that sets `threadId` itself keeps it — that is how the + // mis-threaded case below is expressed. + if (replyDoc.threadId === undefined) replyDoc.threadId = reqDoc.id; // 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. @@ -46,6 +54,16 @@ function simulatedVtaTransport(vta, holder, dispatch, replySenderVid) { senderEncryptionKey: vta.encSk, receiverEncryptionKey: holder.encPk, }); + // Honour the transport contract: the channel decides which frame is its + // reply, and the connection only resolves a waiter whose predicate says + // yes. A double that just returned the bytes would never exercise the + // correlation this seam exists for. + if (options.claims) { + this.lastClaim = await options.claims(sealed.bytes); + if (!this.lastClaim) { + throw new Error("simulated VTA: the channel did not claim this reply"); + } + } return sealed.bytes; }, }; @@ -78,7 +96,7 @@ function makeChannel(dispatch, replySenderVid) { signingPublicKey: vta.signPk, }, }); - return { channel, holder, vta }; + return { channel, holder, vta, transport }; } const LIST = "https://trusttasks.org/spec/vault/list/0.2"; @@ -113,17 +131,42 @@ test("TspChannel decodes a trust-task-error reply into a typed VtaClientError", ); }); -test("TspChannel rejects a reply from the wrong sender VID", async () => { +test("TspChannel never accepts a reply sealed by the wrong sender VID", async () => { // The simulated VTA seals as a *different* VID than the channel expects. - const { channel } = makeChannel( + const { channel, transport } = makeChannel( () => ({ type: LIST_RESP, payload: {} }), "did:web:imposter.example", ); const env = buildTrustTask(LIST, {}, { issuer: "did:web:holder.example", recipient: "did:web:vta.example" }); - await assert.rejects( - () => channel.send(env, { expectedResponseType: LIST_RESP }), - (e) => e.code === "e.p.msg.unauthorized", - ); + await assert.rejects(() => channel.send(env, { expectedResponseType: LIST_RESP })); + + // The property is unchanged — an imposter's frame is never this request's + // answer — but where it is decided has moved, and that is the point. The + // channel used to accept the frame and then throw `unauthorized`; it now + // declines to claim it at all. + // + // That matters on a shared socket. A frame from someone else is not an error + // for *this* request, it is simply not its reply: refusing it here would let + // any peer with socket access fail an unrelated in-flight operation. Declined + // instead, it falls through to the unsolicited-inbound path, where + // `unpackInboundTsp` resolves the claimed sender's own keys and fails the + // unpack — so an imposter is still refused, by the code whose job that is. + assert.equal(transport.lastClaim, false, "an imposter's frame must not be claimed"); +}); + +test("TspChannel does not claim a reply threaded to a different request", async () => { + // Correlation, not just crypto: a document the VTA legitimately sealed to us + // but threaded to some other request is not this request's reply. Under the + // old FIFO waiter it would have been taken as one — which is exactly what an + // executor-initiated push arriving mid-request looks like. + const { channel, transport } = makeChannel(() => ({ + type: LIST_RESP, + threadId: "urn:uuid:some-other-request", + payload: { entries: [], truncated: false }, + })); + const env = buildTrustTask(LIST, {}, { issuer: "did:web:holder.example", recipient: "did:web:vta.example" }); + await assert.rejects(() => channel.send(env, { expectedResponseType: LIST_RESP })); + assert.equal(transport.lastClaim, false, "a mis-threaded document must not be claimed"); }); test("VtaSession routes over TSP when present (TSP > DIDComm > REST)", async () => { diff --git a/packages/core/tests/tsp.inbound.mjs b/packages/core/tests/tsp.inbound.mjs new file mode 100644 index 0000000..424d3e2 --- /dev/null +++ b/packages/core/tests/tsp.inbound.mjs @@ -0,0 +1,164 @@ +// An executor-initiated TSP frame becomes a verified inbound message — or is +// refused before anything downstream could act on it. +// +// The refusals matter more than the happy path here. This is the entry point +// for `task-consent` and step-up requests arriving over TSP, which are the +// documents a human is shown and asked to authorise: a frame that reaches the +// pipeline having only *claimed* an identity is a prompt attributed to an +// executor that did not send it. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +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"; + +const utf8 = new TextEncoder(); + + +function tspIdentity(vid) { + const sign = ed25519.utils.randomSecretKey(); + const encr = x25519.utils.randomSecretKey(); + return { + vid, + signSk: sign, + signPk: ed25519.getPublicKey(sign), + encSk: encr, + encPk: x25519.getPublicKey(encr), + }; +} + +const holder = tspIdentity("did:peer:2holder"); +const executor = tspIdentity("did:web:vta.example"); + +function holderIdentity() { + return { + vid: holder.vid, + signingPrivateKey: holder.signSk, + encryptionPrivateKey: holder.encSk, + encryptionPublicKey: holder.encPk, + }; +} + +/** Resolves whatever VID it is given to `endpoint`'s keys. */ +function resolverFor(endpoint) { + return async (vid) => ({ + vid, + encryptionPublicKey: endpoint.encPk, + signingPublicKey: endpoint.signPk, + }); +} + +async function sealed(payload, { from = executor, senderVid = from.vid } = {}) { + const out = await pack( + utf8.encode(typeof payload === "string" ? payload : JSON.stringify(payload)), + senderVid, + holder.vid, + { + senderSigningKey: from.signSk, + senderEncryptionKey: from.encSk, + receiverEncryptionKey: holder.encPk, + }, + ); + return out.bytes; +} + +const DOC = { + id: "urn:uuid:doc-1", + type: "https://trusttasks.org/spec/task-consent/request/0.1", + issuer: executor.vid, + payload: { challenge: "c", payloadDigest: "zQm…" }, +}; + +test("a verified frame becomes the message shape the inbound pipeline consumes", async () => { + const message = await unpackInboundTsp(await sealed(DOC), { + holder: holderIdentity(), + resolveSender: resolverFor(executor), + }); + + // The document is the body, exactly as the DIDComm binding envelope carries + // it — so the proof check, the enrolled-executor check and the §7.2 item 11 + // dedup claim all see what they already expect. + assert.deepEqual(message.body, DOC); + assert.equal(message.type, TRUST_TASK_ENVELOPE_TYPE); + // `id` is the DOCUMENT's id, not a transport id. TSP has none to borrow, and + // §7.2 item 11 says a transport identifier must not substitute for it anyway. + assert.equal(message.id, DOC.id); + // `from` is the PROVEN sender, established by unpack — not the VID the frame + // named on the way in. + assert.equal(message.from, executor.vid); + assert.deepEqual(message.to, [holder.vid]); + assert.equal(message.transport, "tsp"); +}); + +test("a frame naming a sender whose keys do not verify it is refused", async () => { + // The frame claims to come from the executor, but is signed and sender-bound + // by someone else's keys. The cleartext VID is the routing hint; the crypto + // is what decides, and it says no. + const imposter = tspIdentity("did:web:imposter.example"); + const bytes = await sealed(DOC, { from: imposter, senderVid: executor.vid }); + + await assert.rejects( + () => + unpackInboundTsp(bytes, { + holder: holderIdentity(), + // Resolves the *claimed* sender to the real executor's keys, which is + // exactly what production does. + resolveSender: resolverFor(executor), + }), + (e) => e.code === "e.p.msg.unauthorized", + ); +}); + +test("a sender whose DID will not resolve is refused, not assumed", async () => { + const bytes = await sealed(DOC); + await assert.rejects( + () => + unpackInboundTsp(bytes, { + holder: holderIdentity(), + resolveSender: async () => { + throw new Error("did document unreachable"); + }, + }), + (e) => e.code === "e.client.parse" && /cannot resolve sender/.test(e.message), + ); +}); + +test("a payload that is not JSON is refused", async () => { + const bytes = await sealed("not json at all"); + await assert.rejects( + () => + unpackInboundTsp(bytes, { + holder: holderIdentity(), + resolveSender: resolverFor(executor), + }), + (e) => e.code === "e.client.parse", + ); +}); + +test("a payload that is not a Trust-Task document is refused", async () => { + // No `id`, so nothing downstream could de-duplicate or recover it: the dedup + // claim and the pending-inbound record are both keyed on the document id, and + // an inbound with no stable identity would re-prompt on every redelivery. + const bytes = await sealed({ type: "something", payload: {} }); + await assert.rejects( + () => + unpackInboundTsp(bytes, { + holder: holderIdentity(), + resolveSender: resolverFor(executor), + }), + (e) => e.code === "e.client.parse" && /not a Trust-Task document/.test(e.message), + ); +}); + +test("bytes that are not a TSP envelope are refused", async () => { + await assert.rejects( + () => + unpackInboundTsp(new Uint8Array([1, 2, 3, 4]), { + holder: holderIdentity(), + resolveSender: resolverFor(executor), + }), + (e) => e.code === "e.client.parse" && /unreadable envelope/.test(e.message), + ); +}); diff --git a/packages/core/tests/tsp.mediator-transport.mjs b/packages/core/tests/tsp.mediator-transport.mjs index f56145d..b07907b 100644 --- a/packages/core/tests/tsp.mediator-transport.mjs +++ b/packages/core/tests/tsp.mediator-transport.mjs @@ -13,9 +13,9 @@ function fakeConn() { sendBinary(bytes) { sent.push(bytes); }, - awaitTspFrame(timeoutMs) { + awaitTspFrame(timeoutMs, claims) { return new Promise((resolve, reject) => { - pending = { resolve, reject, timeoutMs }; + pending = { resolve, reject, timeoutMs, claims }; }); }, // test helpers @@ -28,6 +28,11 @@ function fakeConn() { pendingTimeout() { return pending.timeoutMs; }, + /** The predicate the transport registered — the connection needs one to + * tell this request's reply from an unsolicited frame on the same socket. */ + pendingClaims() { + return pending.claims; + }, }; } @@ -37,7 +42,7 @@ test("sends the packed bytes as a binary frame and resolves the awaited reply", const packed = new Uint8Array([0xf8, 1, 2, 3]); const reply = new Uint8Array([0xf8, 9, 8, 7]); - const p = transport.sendAndAwaitReply(packed); + const p = transport.sendAndAwaitReply(packed, { claims: () => true }); assert.equal(conn.sent.length, 1); assert.deepEqual(conn.sent[0], packed); assert.equal(conn.pendingTimeout(), 1234); // default timeout used @@ -49,7 +54,10 @@ test("sends the packed bytes as a binary frame and resolves the awaited reply", test("per-call timeout overrides the default", async () => { const conn = fakeConn(); const transport = new MediatorSessionTspTransport({ connection: conn, timeoutMs: 1234 }); - const p = transport.sendAndAwaitReply(new Uint8Array([0xf8]), { timeoutMs: 50 }); + const p = transport.sendAndAwaitReply(new Uint8Array([0xf8]), { + timeoutMs: 50, + claims: () => true, + }); assert.equal(conn.pendingTimeout(), 50); conn.deliver(new Uint8Array([0xf8, 1])); await p; @@ -65,19 +73,39 @@ test("a send failure surfaces e.client.unsupported (safe fallback, pre-send)", a }, }; const transport = new MediatorSessionTspTransport({ connection: conn }); - await assert.rejects(transport.sendAndAwaitReply(new Uint8Array([0xf8])), (err) => { - assert.equal(err.code, "e.client.unsupported"); - return true; - }); + await assert.rejects( + transport.sendAndAwaitReply(new Uint8Array([0xf8]), { claims: () => true }), + (err) => { + assert.equal(err.code, "e.client.unsupported"); + return true; + }, + ); }); test("a reply timeout surfaces e.client.network (no retry, post-send)", async () => { const conn = fakeConn(); const transport = new MediatorSessionTspTransport({ connection: conn }); - const p = transport.sendAndAwaitReply(new Uint8Array([0xf8, 5])); + const p = transport.sendAndAwaitReply(new Uint8Array([0xf8, 5]), { claims: () => true }); conn.fail(new Error("timed out awaiting reply frame")); await assert.rejects(p, (err) => { assert.equal(err.code, "e.client.network"); return true; }); }); + +test("refuses to send without a claims predicate", async () => { + // Registering a waiter with no way to recognise its own reply is how a + // consent push meant for the inbox gets swallowed as somebody's answer. The + // refusal is `unsupported` — pre-send, so a VtaSession simply moves to the + // next channel rather than treating it as a possibly-applied mutation. + const conn = fakeConn(); + const transport = new MediatorSessionTspTransport({ connection: conn }); + await assert.rejects( + transport.sendAndAwaitReply(new Uint8Array([0xf8])), + (err) => { + assert.equal(err.code, "e.client.unsupported"); + return true; + }, + ); + assert.equal(conn.sent.length, 0, "nothing may reach the VTA"); +}); diff --git a/packages/core/tests/vta.outbound-signing.mjs b/packages/core/tests/vta.outbound-signing.mjs index 226bf74..b16c2fb 100644 --- a/packages/core/tests/vta.outbound-signing.mjs +++ b/packages/core/tests/vta.outbound-signing.mjs @@ -148,7 +148,7 @@ test("TSP: the sealed document carries a proof, distinct from the outer signatur let received; const transport = { - async sendAndAwaitReply(bytes) { + async sendAndAwaitReply(bytes, options = {}) { const opened = await unpack(bytes, { receiverDecryptionKey: vtaEncSk, senderEncryptionKey: x25519.getPublicKey(holderEncSk), @@ -157,7 +157,13 @@ test("TSP: the sealed document carries a proof, distinct from the outer signatur received = JSON.parse(fromUtf8.decode(opened.payload)); const reply = await pack( utf8.encode( - JSON.stringify({ type: `${VAULT_DELETE}#response`, payload: { deleted: true } }), + // `threadId` threads to the request, as the VTA's `respond_with` + // does — the channel will not claim a reply without it. + JSON.stringify({ + type: `${VAULT_DELETE}#response`, + threadId: received.id, + payload: { deleted: true }, + }), ), vtaVid, holderVid, @@ -167,6 +173,9 @@ test("TSP: the sealed document carries a proof, distinct from the outer signatur receiverEncryptionKey: x25519.getPublicKey(holderEncSk), }, ); + if (options.claims && !(await options.claims(reply.bytes))) { + throw new Error("the channel did not claim this reply"); + } return reply.bytes; }, }; diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index a98fe11..2bdeefc 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -39,6 +39,7 @@ import { resolveVtaServices, type VtaServices, resolveVtaTspEndpoint, + unpackInboundTsp, RestChannel, TspChannel, MediatorSessionTspTransport, @@ -1653,6 +1654,14 @@ async function createWarmSession( // Return the promise: the transport awaits it and acks only once the // message is durably recorded (R1.6). conn.onInbound((message) => onInboundMessage(conn, identity, signing, vtaDid, message)); + // The same inbox over TSP. One socket carries both, so an executor that + // pushes over TSP reaches the identical pipeline — same proof check, same + // dedup, same persist-before-ack — with `unpackInboundTsp` supplying the + // one thing that differs: turning a sealed frame into the message shape, + // and proving the sender while it does. + conn.onInboundTsp((bytes) => + onInboundTspFrame(conn, identity, signing, vtaDid, bytes, false), + ); } return conn; } @@ -1776,6 +1785,9 @@ async function createApproverWarmSession(vtaDid: string): Promise + onInboundTspFrame(conn, approver.identity, approver.signing, vtaDid, bytes, true), + ); conn.onInbound((message) => onInboundMessage(conn, approver.identity, approver.signing, vtaDid, message, true), ); @@ -2026,6 +2038,55 @@ async function drainPendingInbound(vtaDids: readonly string[]): Promise { * behaviour so a fix upstream shows up as a failing test rather than a * silent change. */ +/** + * Verify an inbound TSP frame and hand it to the same pipeline DIDComm uses. + * + * Everything security-bearing downstream reads the Trust-Task **document** — + * the proof check, the enrolled-executor check, the §7.2 item 11 dedup claim — + * so the two transports converge the moment the document is in hand. What this + * adds is the part only TSP needs: the frame arrives sealed, naming its sender + * in cleartext, and `unpackInboundTsp` turns that claim into a proven identity + * or refuses. + * + * A refusal throws, which withholds the mediator's ack (vti-didcomm-js + * >=0.7.0) and leaves the frame queued for redelivery. That is the right + * outcome for a transient failure — a DID document that would not resolve, say + * — and harmless for a permanent one: an unverifiable frame is refused again on + * every redelivery and never reaches a human either way. + */ +async function onInboundTspFrame( + conn: MediatorConnection, + identity: Identity, + signing: SigningIdentity, + vtaDid: string, + bytes: Uint8Array, + isApprover: boolean, +): Promise { + let message; + try { + message = await unpackInboundTsp(bytes, { + holder: tspHolderIdentityFromSecret(identity.did, signing.privateKey), + // Despite the name this resolves any DID, which is what an inbound + // sender needs: an enrolled executor may be this device's VTA or an + // operator-enrolled control plane. Whether the sender is one we accept + // is decided downstream, on the document's proof — not here, and not on + // the strength of the transport. + resolveSender: resolveVtaTspEndpoint, + }); + } catch (err) { + // Logged, not swallowed: a frame that repeatedly fails to verify is a + // routing or enrolment problem someone has to see, and the silent-drop + // version of this is precisely the failure mode that made an un-prompted + // consent indistinguishable from one that never arrived. + console.warn( + "[pnm inbound] refusing inbound TSP frame:", + err instanceof Error ? err.message : String(err), + ); + throw err; + } + await onInboundMessage(conn, identity, signing, vtaDid, message, isApprover); +} + async function onInboundMessage( conn: MediatorConnection, identity: Identity,