Skip to content

feat(extension): TSP transport for trust tasks (TSP > DIDComm > REST) - #79

Merged
stormer78 merged 1 commit into
mainfrom
feat/tsp-transport
Jul 5, 2026
Merged

feat(extension): TSP transport for trust tasks (TSP > DIDComm > REST)#79
stormer78 merged 1 commit into
mainfrom
feat/tsp-transport

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

What

Route trust tasks over TSP (Trust Spanning Protocol) when a VTA advertises a TSPTransport service, ahead of DIDComm and REST. TSP is sender-authenticated by its envelope (like DIDComm authcrypt), so it carries the same canonical Trust-Task envelope with no bearer.

Transport: one socket, multiplexed

TSP rides the same warm mediator socket as DIDComm — the mediator multiplexes both (binary 0xF8 → TSP, text → DIDComm), so there is no second socket and no one-socket-per-DID conflict with the wallet's DIDComm inbox. MediatorSessionTspTransport sends the packed envelope as a binary frame and awaits the sealed reply (FIFO — a VtaSession drives one request at a time). Replies arrive back on the same session via the new onTspFrame demux.

Requires @openvtc/vti-didcomm-js ^0.6.0 (OpenVTC/vti-didcomm-js#4, merged — needs publish before CI here goes green).

Changes

  • pnm-core: MediatorConnection gains sendBinary + awaitTspFrame; new MediatorSessionTspTransport; resolveVtaServices detects the type-based TSPTransport service; resolveVtaTspEndpoint + tspHolderIdentityFromSecret (X25519 derived from the Ed25519 root, matching the holder did:peer keyAgreement key).
  • offscreen getVtaSession: builds the TSP channel from the pooled warm session, first in the priority chain.
  • Error phasing: a send failure (pre-send, nothing reached the VTA) → e.client.unsupported → safe fallback to DIDComm; a reply timeout (post-send, a mutation may have applied) → e.client.network → hard fail, no retry.
  • preferTsp setting (default ON; options-page toggle) to pin a VTA to DIDComm/REST if a mediator's TSP delivery misbehaves.
  • Popup transports label shows TSP (backfilled onto existing connections by the transport refresh — no re-onboard).

Validation

Live against a TSP-enabled VTA + mediator: vault list, proxy-login (sealed session blob), add (mutation + sealed secret), and delete all round-trip over TSP, with the DIDComm inbox coexisting on the one socket. Core suite: 61 pass (adds MediatorSessionTspTransport tests).

Merge order

Merge + publish @openvtc/vti-didcomm-js@0.6.0 first (#4), then this.

Route trust tasks over TSP (Trust Spanning Protocol) when a VTA advertises
a `TSPTransport` service, ahead of DIDComm and REST. TSP is sender-
authenticated by its envelope (like DIDComm authcrypt), so it carries the
same canonical Trust-Task envelope with no bearer.

Transport: TSP rides the SAME warm mediator socket as DIDComm — the
mediator multiplexes both (binary 0xF8 -> TSP, text -> DIDComm), so there
is no second socket and no one-socket-per-DID conflict with the wallet's
DIDComm inbox. `MediatorSessionTspTransport` sends the packed envelope as
a binary frame and awaits the sealed reply (FIFO; a VtaSession drives one
request at a time). Reply frames arrive back on the same session via the
new `onTspFrame` demux (requires @openvtc/vti-didcomm-js ^0.6.0).

- pnm-core: MediatorConnection gains `sendBinary` + `awaitTspFrame`;
  `MediatorSessionTspTransport`; `resolveVtaServices` detects the
  type-based `TSPTransport` service; `resolveVtaTspEndpoint` +
  `tspHolderIdentityFromSecret` (X25519 derived from the Ed25519 root,
  matching the holder did:peer keyAgreement key).
- offscreen `getVtaSession`: builds the TSP channel from the pooled warm
  session, first in the priority chain.
- Error phasing: a send failure (pre-send, nothing reached the VTA) is
  `e.client.unsupported` -> safe fallback to DIDComm; a reply timeout
  (post-send, a mutation may have applied) is `e.client.network` -> hard
  fail, no retry.
- `preferTsp` setting (default ON; options-page toggle) to pin a VTA to
  DIDComm/REST if a mediator's TSP delivery misbehaves.
- Popup transports label shows TSP (backfilled onto existing connections
  by the transport refresh — no re-onboard).

Live-validated against a TSP-enabled VTA + mediator: vault list, proxy-
login (sealed session blob), add (mutation + sealed secret), and delete
all round-trip over TSP, with the DIDComm inbox coexisting on the one
socket. Adds `MediatorSessionTspTransport` unit tests.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 merged commit b0e652a into main Jul 5, 2026
1 check passed
@stormer78
stormer78 deleted the feat/tsp-transport branch July 5, 2026 00:31
stormer78 added a commit that referenced this pull request Aug 29, 2026
Provisioning was the last VTA operation with no transport chain. Every other
one — vault, contexts, dids, device wake, acl — has gone through
`TrustTaskSender` since #79, so it runs over TSP, DIDComm or REST according to
what the agent publishes. `runProvisionIntegration` still took a
`DidcommMessageBridge`, and `offscreen.ts` built its own mediator session
rather than calling `getVtaSession`. A wallet already connected over TSP had to
open a DIDComm session to onboard, and a VTA advertising no DIDComm could not
be onboarded at all.

The VTA has served this operation through the shared dispatcher for some time
(`TASK_PROVISION_INTEGRATION_0_3` → `trust_tasks/provision_integration.rs`),
taking the same request body and returning the same response body as the
bespoke DIDComm handler beside it. We were using the bespoke one, and paid for
it a second way: that handler labels its reply from a hand-written version→URI
map which was not moved when the router cut over to 0.3, so a provisioning that
had fully succeeded — bundle sealed, admin rolled over, secret written — came
back labelled `provision/integration/0.1#response` and was rejected here as an
unexpected reply type. The dispatcher has no such map; it sets the `#response`
fragment on the request URI it just parsed, so the class of bug does not exist
on that path. (VTI #1202 fixes the map itself, for the CLI clients still on it.)

`sendProvisionIntegration` is now `buildTrustTask` + `sender.send`, and reads
almost exactly like `swapAcl`, which has been the template for this shape since
the ACL swap was collapsed.

**The session speaks as the ephemeral.** The holder is what provisioning is
about to mint, so it cannot exist yet, and the warm mediator pool authenticates
as the holder — it calls `loadHolder` itself. `getVtaSession` therefore splits:
`buildVtaSession` takes the identity and a `MediatorConnector`, and
`getVtaSession` is the holder-over-the-warm-pool case of it. Onboarding passes
the ephemeral and connections it opens and closes itself. Handing the ephemeral
a pooled connection would have sent its request under a DID the operator never
granted, which is the failure this split exists to make unrepresentable.

Authorisation is unchanged and needed no VTA-side work: `auth_from_message` is
`auth_from_did(sender)`, and the dispatcher's trust-task path resolves claims
the same way, so the ACL grant the operator just made authenticates the call
identically whichever transport carries it.

**DIDComm leads for onboarding, deliberately.** The chain is otherwise
TSP-first. But a TSP reply timeout is a hard failure by design — a mutation may
already have applied, and provisioning is as mutating as an operation gets —
and TSP delivery to a *just-granted ephemeral* has never been exercised: the
enrolled holder's mailbox is live-validated, the ephemeral's is a transient auth
session, and the one headless attempt against a mailbox-less did:key timed out.
Leading with DIDComm leaves the proven path exactly as it was while still
letting a VTA that advertises no DIDComm onboard over TSP or REST. Promote TSP
once an ephemeral round-trip is confirmed against a live mediator.

Three consequences worth naming:

- **A mediator is no longer mandatory.** `MediatorRequiredError` now fires only
  when a mediator is the sole remaining possibility and none was supplied,
  rather than before any transport is considered.
- **A mediator that will not connect skips its channel** instead of failing the
  session, mirroring what the TSP branch already did. Safe for the same reason:
  pre-send, so nothing has been dispatched and nothing can have been applied
  twice — the distinction `VtaSession` draws when it falls back on
  `e.client.unsupported` but never on a post-send failure.
- **The request body is lowerCamelCase** (`createContext`), the canonical 0.2+
  form the registry declares and the generated payload type now enforces. The
  Rust struct still carries snake_case aliases, so the old spelling would also
  be accepted; that is a fold on the VTA's side, not a reason to keep sending
  the legacy form. The signed VP is relayed byte-for-byte, as always.

`ProvisionProblemReportError` is gone with the DIDComm-specific error channel.
`provisionRefusalOf` replaces it, reading the code and `details.candidates` off
`VtaClientError.details` — fields, not a parsed message (R3.7). The popup's
context picker is untouched: offscreen still forwards `{code, candidates}` and
`onboard-view` still compares against `PROVISION_CONTEXT_REQUIRED`.

Needs VTI #1204, which makes the dispatcher emit that canonical code with
structured candidates. Without it the spine refuses an ambiguous context as
`malformedRequest` with the candidates rendered into a sentence, and the picker
would have nothing to read — trading a working recovery UX for a string match.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant